This commit is contained in:
vastxie
2025-05-31 02:28:46 +08:00
parent 0f7adc5c65
commit 86e2eecc1f
1808 changed files with 183083 additions and 86701 deletions

200
chat/src/App.vue Normal file
View File

@@ -0,0 +1,200 @@
<script setup lang="ts">
import favicon from '@/assets/favicon.ico'
import Watermark from '@/components/common/Watermark/index.vue'
import HtmlDialog from '@/components/HtmlDialog.vue'
import { initWechatLogin } from '@/services/wechatLogin' // 导入微信登录相关功能
import { useAuthStore, useGlobalStoreWithOut } from '@/store'
import { DIALOG_TABS } from '@/store/modules/global'
import { ClientJS } from 'clientjs'
import { computed, onMounted, ref } from 'vue'
import { ss } from './utils/storage'
const client = new ClientJS()
const authStore = useAuthStore()
const useGlobalStore = useGlobalStoreWithOut()
const sharedHtml = ref('')
// 获取客户端指纹
useGlobalStore.updateFingerprint(client.getFingerprint())
// 获取配置
const faviconPath = computed(() => authStore.globalConfig?.clientFaviconPath || favicon)
const isAutoOpenNotice = computed(() => Number(authStore.globalConfig?.isAutoOpenNotice) === 1)
const isLogin = computed(() => authStore.isLogin)
const wechatSilentLoginStatus = computed(
() => Number(authStore.globalConfig?.wechatSilentLoginStatus) === 1
)
const showWatermark = computed(() => Number(authStore.globalConfig?.showWatermark) === 1)
// 默认不清除缓存需要在后台配置中设置为1才开启自动清除
const clearCacheEnabled = computed(() => Number(authStore.globalConfig?.clearCacheEnabled) === 1)
/**
* 清除所有本地缓存
* 包括localStorage、sessionStorage和indexedDB缓存
* @param {boolean} forceClear 强制清除缓存,不考虑配置
*/
function clearAllCache(forceClear = false) {
// 如果未启用清除缓存且未指定强制清除,则跳过
if (!clearCacheEnabled.value && !forceClear) {
console.log('缓存清除未启用')
return
}
// 获取当前登录状态
const isUserLoggedIn = authStore.isLogin
// 如果用户已登录,则不清除缓存
if (isUserLoggedIn) {
console.log('用户已登录,跳过缓存清除')
return
}
// 以下是用户未登录时的缓存清除逻辑
// 保存所有本地存储的关键数据
const savedData: Record<string, string | null> = {}
// 保存主题
savedData['theme'] = localStorage.getItem('theme') || 'light'
// 保存其他非登录相关但需要保留的数据
const preserveKeys = ['appLanguage', 'agreedToUserAgreement']
// 保存这些数据
preserveKeys.forEach(key => {
const value = localStorage.getItem(key)
if (value) savedData[key] = value
const ssValue = sessionStorage.getItem(key)
if (ssValue) savedData[`ss_${key}`] = ssValue
})
// 清除localStorage
localStorage.clear()
// 清除sessionStorage
sessionStorage.clear()
// 恢复所有保存的数据
Object.keys(savedData).forEach(key => {
const value = savedData[key]
if (value !== null) {
if (key.startsWith('ss_')) {
// 恢复到sessionStorage
sessionStorage.setItem(key.substring(3), value)
} else {
// 恢复到localStorage
localStorage.setItem(key, value)
}
}
})
// 清除indexedDB数据库
if (window.indexedDB.databases) {
window.indexedDB
.databases()
.then(databases => {
databases.forEach(database => {
if (database.name) {
window.indexedDB.deleteDatabase(database.name)
}
})
})
.catch(() => {
// 如果浏览器不支持databases()方法,使用兼容性方式处理
console.warn('无法直接清除IndexedDB数据库')
})
}
// 清除应用缓存(如果支持)
if ('caches' in window) {
caches.keys().then(keys => {
keys.forEach(key => {
caches.delete(key)
})
})
}
console.log('本地缓存已清除')
}
async function loadBaiduCode() {
const baiduCode = authStore.globalConfig?.baiduCode
if (!baiduCode) return
const scriptElem = document.createElement('script')
scriptElem.innerHTML = baiduCode.replace(/<script[\s\S]*?>([\s\S]*?)<\/script>/gi, '$1')
document.head.appendChild(scriptElem)
}
function setDocumentTitle() {
document.title = authStore.globalConfig?.siteName || 'AI'
}
function noticeInit() {
const showNotice = ss.get('showNotice')
if ((!showNotice || Date.now() > Number(showNotice)) && isAutoOpenNotice.value) {
useGlobalStore.updateSettingsDialog(true, DIALOG_TABS.NOTICE)
}
}
/**
* 检测当前浏览器是否是微信内置浏览器
* 区分微信和企业微信只有微信浏览器返回true
* @returns {boolean} 如果是微信浏览器返回 true否则返回 false
*/
function isWechatBrowser(): boolean {
const ua = navigator.userAgent.toLowerCase()
// 检查是否是企业微信
const isWXWork = ua.indexOf('wxwork') !== -1
// 检查是否是微信,排除企业微信的情况
const isWeixin = !isWXWork && ua.indexOf('micromessenger') !== -1
return isWeixin
}
onMounted(async () => {
// 设置网站标题
setDocumentTitle()
// 加载百度统计代码(如果有)
loadBaiduCode()
// 如果开启微信静默登录,并且是微信浏览器,执行微信登录逻辑
if (wechatSilentLoginStatus.value && isWechatBrowser()) {
await initWechatLogin() // 初始化微信登录
}
// 尝试自动清除缓存
clearAllCache()
/* 动态设置网站ico svg格式 */
const link = document.createElement('link')
link.rel = 'shortcut icon'
link.href = faviconPath.value
link.type = 'image/png' // 设置正确的图像类型
// 移除已存在的favicon链接防止冲突
const existingFavicons = document.querySelectorAll('link[rel="shortcut icon"], link[rel="icon"]')
existingFavicons.forEach(node => node.parentNode?.removeChild(node))
// 添加新的favicon链接
document.head.appendChild(link)
// 初始化通知提示
await noticeInit()
})
</script>
<template>
<!-- 水印组件如果启用 -->
<Watermark v-if="showWatermark"></Watermark>
<!-- 主要内容使用router-view -->
<router-view />
<!-- 共享内容对话框 -->
<HtmlDialog :visible="useGlobalStore.htmlDialog" :html="sharedHtml" />
<!-- 全局图片预览器 -->
<GlobalImageViewer />
</template>

40
chat/src/api/appStore.ts Normal file
View File

@@ -0,0 +1,40 @@
import { get, post } from '@/utils/request'
/* 查询app分组 */
export function fetchQueryAppCatsAPI<T>(): Promise<T> {
return get<T>({ url: '/app/queryCats' })
}
/* 查询全量app列表 */
export function fetchQueryAppsAPI<T>(): Promise<T> {
return get<T>({
url: '/app/list',
})
}
export function fetchSearchAppsAPI<T>(data: { keyword: string }): Promise<T> {
return post<T>({
url: '/app/searchList',
data,
})
}
/* 查询个人app列表 */
export function fetchQueryMineAppsAPI<T>(): Promise<T> {
return get<T>({
url: '/app/mineApps',
})
}
/* 收藏app */
export function fetchCollectAppAPI<T>(data: { appId: number }): Promise<T> {
return post<T>({ url: '/app/collect', data })
}
/* 查询单个分类 */
export function fetchQueryOneCatAPI<T>(data): Promise<T> {
return get<T>({
url: '/app/queryOneCat',
data,
})
}

28
chat/src/api/balance.ts Normal file
View File

@@ -0,0 +1,28 @@
import { get, post } from '@/utils/request'
/* get rechargeLog */
export function fetchGetRechargeLogAPI<T>(data: { page?: number; size?: number }): Promise<T> {
return get<T>({
url: '/balance/rechargeLog',
data,
})
}
/* query balance */
export function fetchGetBalanceQueryAPI<T>(): Promise<T> {
return get<T>({
url: '/balance/query',
})
}
export function fetchVisitorCountAPI<T>(): Promise<T> {
return get<T>({
url: '/balance/getVisitorCount',
})
}
export function fetchSyncVisitorDataAPI<T>(): Promise<T> {
return post<T>({
url: '/balance/inheritVisitorData',
})
}

54
chat/src/api/chatLog.ts Normal file
View File

@@ -0,0 +1,54 @@
import { get, post } from '@/utils/request'
/* 删除对话记录 */
export function fetchDelChatLogAPI<T>(data: { id: number }): Promise<T> {
return post<T>({
url: '/chatlog/del',
// url: '/chatlog/deleteChatsAfterId',
data,
})
}
/* 删除一组对话记录 */
export function fetchDelChatLogByGroupIdAPI<T>(data: { groupId: number }): Promise<T> {
return post<T>({
url: '/chatlog/delByGroupId',
data,
})
}
/* 删除一组对话记录 */
export function fetchDeleteGroupChatsAfterIdAPI<T>(data: { id: number }): Promise<T> {
return post<T>({
url: '/chatlog/deleteChatsAfterId',
data,
})
}
/* 查询x组对话信息 */
export function fetchQueryChatLogListAPI<T>(data: { groupId: number }): Promise<T> {
return get<T>({
url: '/chatlog/chatList',
data,
})
}
/* 查询单个应用的对话信息 */
export function fetchQueryChatLogByAppIdAPI<T>(data: {
page?: number
size?: number
appId: number
}): Promise<T> {
return get<T>({
url: '/chatlog/byAppId',
data,
})
}
/* 查询单条消息的状态和内容 */
export function fetchQuerySingleChatLogAPI<T>(data: { chatId: number }): Promise<T> {
return get<T>({
url: '/chatlog/querySingleChat',
data,
})
}

9
chat/src/api/config.ts Normal file
View File

@@ -0,0 +1,9 @@
import { get } from '@/utils/request'
/* query globe config */
export function fetchQueryConfigAPI<T>(data: any) {
return get<T>({
url: '/config/queryFront',
data,
})
}

21
chat/src/api/crami.ts Normal file
View File

@@ -0,0 +1,21 @@
import { get, post } from '@/utils/request'
/* use crami */
export function fetchUseCramiAPI<T>(data: { code: string }): Promise<T> {
return post<T>({
url: '/crami/useCrami',
data,
})
}
/* get all crami package */
export function fetchGetPackageAPI<T>(data: {
status: number
type?: number
size?: number
}): Promise<T> {
return get<T>({
url: '/crami/queryAllPackage',
data,
})
}

8
chat/src/api/global.ts Normal file
View File

@@ -0,0 +1,8 @@
import { get } from '@/utils/request'
/* get notice */
export function fetchGetGlobalNoticeAPI<T>(): Promise<T> {
return get<T>({
url: '/config/notice',
})
}

53
chat/src/api/group.ts Normal file
View File

@@ -0,0 +1,53 @@
import { get, post } from '@/utils/request'
/* 创建新的对话组 */
export function fetchCreateGroupAPI<T>(data?: {
appId?: number
modelConfig?: any
params?: string
}): Promise<T> {
return post<T>({
url: '/group/create',
data,
})
}
/* 查询对话组列表 */
export function fetchQueryGroupAPI<T>(): Promise<T> {
return get<T>({ url: '/group/query' })
}
/* 通过groupId查询当前对话组的详细信息 */
export function fetchGroupInfoById<T>(groupId: number | string): Promise<T> {
return get<T>({ url: `/group/info/${groupId}` })
}
/* 修改对话组 */
export function fetchUpdateGroupAPI<T>(data?: {
groupId?: number
title?: string
isSticky?: boolean
config?: string
fileUrl?: string
}): Promise<T> {
return post<T>({
url: '/group/update',
data,
})
}
/* 删除对话组 */
export function fetchDelGroupAPI<T>(data?: { groupId: number }): Promise<T> {
return post<T>({
url: '/group/del',
data,
})
}
/* 删除全部对话组 */
export function fetchDelAllGroupAPI<T>(data?: { groupId: number }): Promise<T> {
return post<T>({
url: '/group/delAll',
data,
})
}

181
chat/src/api/index.ts Normal file
View File

@@ -0,0 +1,181 @@
import { get, post } from '@/utils/request'
import { fetchStream } from '@/utils/request/fetch'
import type { AxiosProgressEvent, GenericAbortSignal } from 'axios'
/* 对话聊天 */
export function fetchChatAPIProcess<T = any>(params: {
model: string
modelName: string
modelType: number
modelAvatar?: string
prompt: string
sslUrl?: string
chatId?: string
fileInfo?: string
imageUrl?: string
fileUrl?: string
action?: string
drawId?: string
customId?: string
appId?: number
extraParam?: { size?: string }
usingPluginId?: number
options?: {
groupId: number
usingNetwork: boolean
usingMcpTool: boolean
}
signal?: GenericAbortSignal
onDownloadProgress?: (progressEvent: AxiosProgressEvent) => void
taskId?: string
}) {
// 准备请求数据
const data = {
model: params.model,
modelName: params.modelName,
modelType: params.modelType,
prompt: params.prompt,
fileInfo: params?.fileInfo,
imageUrl: params?.imageUrl,
fileUrl: params?.fileUrl,
extraParam: params?.extraParam,
appId: params?.appId,
options: params.options,
action: params?.action,
customId: params?.customId,
usingPluginId: params?.usingPluginId,
drawId: params?.drawId,
modelAvatar: params?.modelAvatar,
taskId: params?.taskId,
}
// 如果没有进度回调则使用普通POST请求
if (!params.onDownloadProgress) {
return post<T>({
url: '/chatgpt/chat-process',
data,
signal: params.signal,
})
}
// 使用流式请求处理
return new Promise((resolve, reject) => {
// 创建AbortController用于取消请求
const fetchOptions: RequestInit = {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
}
// 如果提供了signal添加到请求选项
if (params.signal) {
// 由于类型不兼容,这里使用类型断言
fetchOptions.signal = params.signal as any
}
fetchStream('/chatgpt/chat-process', fetchOptions, chunk => {
// 调用进度回调模拟axios的onDownloadProgress事件
if (params.onDownloadProgress) {
// 创建一个符合AxiosProgressEvent的对象
const progressEvent: AxiosProgressEvent = {
event: {
target: {
responseText: chunk,
getResponseHeader: (name: string) => null,
},
} as any,
loaded: chunk.length,
total: 0, // 流式响应无法预知总长度
bytes: chunk.length,
lengthComputable: false,
progress: 0,
}
params.onDownloadProgress(progressEvent)
}
})
.then(response => {
resolve({ data: response } as any)
})
.catch(error => {
reject(error)
})
})
}
export function fetchPptCoverAPIProcess<T>(data: {
color?: string
style?: string
title: string
}): Promise<T> {
return post<T>({ url: '/chatgpt/ppt-cover', data }) as Promise<T>
}
/* TTS 文字转语音 */
export function fetchTtsAPIProcess<T>(data: { chatId: number; prompt: string }): Promise<T> {
return post<T>({ url: '/chatgpt/tts-process', data }) as Promise<T>
}
/* 获取个人信息 */
export function fetchGetInfo<T>() {
return get<T>({ url: '/auth/getInfo' })
}
/* 注册 */
export function fetchRegisterAPI<T>(data: {
username: string
password: string
contact: string
code: string
}): Promise<T> {
return post<T>({ url: '/auth/register', data }) as Promise<T>
}
/* 登录 */
export function fetchLoginAPI<T>(data: { username: string; password: string }): Promise<T> {
return post<T>({ url: '/auth/login', data }) as Promise<T>
}
/* 验证码登录 */
export function fetchLoginWithCaptchaAPI<T>(data: { contact: string; code: string }): Promise<T> {
return post<T>({ url: '/auth/loginWithCaptcha', data }) as Promise<T>
}
/* 修改个人信息 */
export function fetchUpdateInfoAPI<T>(data: {
username?: string
avatar?: string
nickname?: string
}): Promise<T> {
return post<T>({ url: '/user/update', data }) as Promise<T>
}
/* 修改密码 */
export function fetchUpdatePasswordAPI<T>(data: { password?: string }): Promise<T> {
return post<T>({ url: '/auth/updatePassword', data }) as Promise<T>
}
/* 获取图片验证码 */
export function fetchCaptchaImg<T>(data: { color: string }): Promise<T> {
return post<T>({ url: '/auth/captcha', data }) as Promise<T>
}
/* 发送邮箱验证码 */
export function fetchSendCode<T>(data: { contact: string; captchaCode: string }): Promise<T> {
return post<T>({ url: '/auth/sendCode', data }) as Promise<T>
}
/* 发送手机验证码 */
export function fetchSendSms<T>(data: { phone: string }): Promise<T> {
return post<T>({ url: '/auth/sendPhoneCode', data }) as Promise<T>
}
/* 发送邮箱验证码 */
export function fetchSendEmailCode<T>(data: {
phone: string
captchaId: string
captchaCode: string
}): Promise<T> {
return post<T>({ url: '/auth/sendEmailCode', data }) as Promise<T>
}

15
chat/src/api/models.ts Normal file
View File

@@ -0,0 +1,15 @@
import { get } from '@/utils/request'
/* query models list */
export function fetchQueryModelsListAPI<T>() {
return get<T>({
url: '/models/list',
})
}
/* query base model config */
export function fetchModelBaseConfigAPI<T>() {
return get<T>({
url: '/models/baseConfig',
})
}

17
chat/src/api/order.ts Normal file
View File

@@ -0,0 +1,17 @@
import { get, post } from '@/utils/request'
/* order buy */
export function fetchOrderBuyAPI<T>(data: { goodsId: number; payType?: string }): Promise<T> {
return post<T>({
url: '/order/buy',
data,
})
}
/* order query */
export function fetchOrderQueryAPI<T>(data: { orderId: string }): Promise<T> {
return get<T>({
url: '/order/queryByOrderId',
data,
})
}

8
chat/src/api/plugin.ts Normal file
View File

@@ -0,0 +1,8 @@
import { get } from '@/utils/request'
/* 查询全量app列表 */
export function fetchQueryPluginsAPI<T>(): Promise<any> {
return get<T>({
url: '/plugin/pluginList',
})
}

15
chat/src/api/share.ts Normal file
View File

@@ -0,0 +1,15 @@
import { get, post } from '@/utils/request'
/* order buy */
export function createShare<T>(data: { htmlContent: string }): Promise<T> {
return post<T>({
url: '/share/create',
data,
})
}
export function getShare<T>(shareCode: string): Promise<T> {
return get<T>({
url: `/share/${shareCode}`,
})
}

15
chat/src/api/signin.ts Normal file
View File

@@ -0,0 +1,15 @@
import { get, post } from '@/utils/request'
/* sign in */
export function fetchSignInAPI<T>(): Promise<T> {
return post<T>({
url: '/signin/sign',
})
}
/* sign log */
export function fetchSignLogAPI<T>(): Promise<T> {
return get<T>({
url: '/signin/signinLog',
})
}

5
chat/src/api/types.ts Normal file
View File

@@ -0,0 +1,5 @@
export interface ResData {
success: boolean
message: string
data: any
}

30
chat/src/api/upload.ts Normal file
View File

@@ -0,0 +1,30 @@
import type { Response } from '@/utils/request'
import { post } from '@/utils/request'
/**
* 上传单个文件
* @param file 要上传的文件
* @param dir 上传目录,默认使用当前日期目录
*/
export function uploadFile<T = any>(file: File, dir?: string): Promise<Response<T>> {
// 如果未提供目录,使用默认的日期格式目录
if (!dir) {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
const currentDate = `${year}${month}/${day}`
dir = `userFiles/${currentDate}`
}
const form = new FormData()
form.append('file', file)
const path = `/upload/file?dir=${encodeURIComponent(dir)}`
return post<T>({
url: path,
data: form,
headers: { 'Content-Type': 'multipart/form-data' },
})
}

116
chat/src/api/user.ts Normal file
View File

@@ -0,0 +1,116 @@
import type { Response } from '@/utils/request'
import { get, post } from '@/utils/request'
/* get wechat-login senceStr */
export function fetchGetQRSceneStrAPI<T>(data: {}): Promise<Response<T>> {
return post<T>({
url: '/official/getQRSceneStr',
data,
})
}
/* get wechat-login qr url */
export function fetchGetQRCodeAPI<T>(data: { sceneStr: string }): Promise<Response<T>> {
return get<T>({
url: '/official/getQRCode',
data,
})
}
/* login by scenceStr */
export function fetchLoginBySceneStrAPI<T>(data: { sceneStr: string }): Promise<Response<T>> {
return post<T>({
url: '/official/loginBySceneStr',
data,
})
}
/* login by code */
export function fetchLoginByCodeAPI<T>(data: { code: string }): Promise<Response<T>> {
return post<T>({
url: '/official/loginByCode',
data,
})
}
/* get wx registery config */
export function fetchGetJsapiTicketAPI<T>(data: { url: string }): Promise<Response<T>> {
return post<T>({
url: '/official/getJsapiTicket',
data,
})
}
/* get wechat-login senceStr */
export function fetchGetQRSceneStrByBindAPI<T>(): Promise<Response<T>> {
return post<T>({
url: '/official/getQRSceneStrByBind',
})
}
/* bind wx by scenceStr */
export function fetchBindWxBySceneStrAPI<T>(data: { sceneStr: string }): Promise<Response<T>> {
return post<T>({
url: '/official/bindWxBySceneStr',
data,
})
}
/* get wx rediriect login url */
export function fetchWxLoginRedirectAPI<T>(data: { url: string }): Promise<Response<T>> {
return post<T>({
url: '/official/getRedirectUrl',
data,
})
}
/* 实名认证 */
export function fetchVerifyIdentityAPI<T>(data: {
name: string
idCard: string
}): Promise<Response<T>> {
return post<T>({
url: '/auth/verifyIdentity',
data,
})
}
/* 手机认证 */
export function fetchVerifyPhoneIdentityAPI<T>(data: {
phone: string
username: string
password: string
code: string
}): Promise<Response<T>> {
return post<T>({
url: '/auth/verifyPhoneIdentity',
data,
})
}
/* 获取旧账号迁移二维码的sceneStr */
export function fetchGetQRSceneStrByOldWechatAPI<T>(): Promise<Response<T>> {
return post<T>({
url: '/official/getQRSceneStrByOldWechat',
})
}
/* 轮询查询旧微信迁移结果 */
export function fetchBindWxByOldWechatAPI<T>(data: { sceneStr: string }): Promise<Response<T>> {
return post<T>({
url: '/official/bindWxByOldWechat',
data,
})
}
/* 获取旧公众号二维码(用于账号迁移)
* 注意:此接口与普通二维码接口区别在于它返回的是旧公众号的二维码
* 后端API: /official/getOldQRCode
*/
export function fetchGetOldQRCodeAPI<T>(data: { sceneStr: string }): Promise<Response<T>> {
console.log('[API调试] 调用getOldQRCode接口, 参数:', data)
return get<T>({
url: '/official/getOldQRCode',
data, // 使用data字段传递参数会被转换为URL参数
})
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="48" viewBox="0 0 24 24" width="48" xmlns="http://www.w3.org/2000/svg" style="flex: 0 0 auto; line-height: 1;"><title>OpenAI</title><path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
chat/src/assets/alipay.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

BIN
chat/src/assets/avatar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

BIN
chat/src/assets/badge.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -0,0 +1,710 @@
[
{
"title": "英语翻译官",
"prompt": "我希望你能担任英语翻译、拼写校对和修辞改进的角色。我会用任何语言和你交流你会识别语言将其翻译并用更为优美和精炼的英语回答我。请将我简单的词汇和句子替换成更为优美和高雅的表达方式确保意思不变但使其更具文学性。请仅回答更正和改进的部分不要写解释。我的第一句话是“how are you ?”,请翻译它。",
"icon": "ri:ai-generate"
},
{
"title": "心理学家",
"prompt": "我想让你扮演一个心理学家。我会告诉你我的想法。我希望你能给我科学的建议,让我感觉更好。我的第一个想法,{ 在这里输入你的想法,如果你解释得更详细,我想你会得到更准确的答案。}",
"icon": "ri:heart-line"
},
{
"title": "产品经理",
"prompt": "请确认我的以下请求。请您作为产品经理回复我。我将会提供一个主题您将帮助我编写一份包括以下章节标题的PRD文档主题、简介、问题陈述、目标与目的、用户故事、技术要求、收益、KPI指标、开发风险以及结论。在我要求具体主题、功能或开发的PRD之前请不要先写任何一份PRD文档。",
"icon": "ri:projector-line"
},
{
"title": "如何学做菜",
"prompt": "我要你做我的私人厨师。我会告诉你我的饮食偏好和过敏,你会建议我尝试的食谱。你应该只回复你推荐的食谱,别无其他。不要写解释。我的第一个请求是“我是一名素食主义者,我正在寻找健康的晚餐点子。”",
"icon": "ri:restaurant-line"
},
{
"title": "规划一个去上海的旅游攻略 参观博物馆",
"prompt": "我想让你做一个旅游指南。我会把我的位置写给你,你会推荐一个靠近我的位置的地方。在某些情况下,我还会告诉您我将访问的地方类型。您还会向我推荐靠近我的第一个位置的类似类型的地方。我的第一个建议请求是“我在上海,我只想参观博物馆。”",
"icon": "ri:map-pin-line"
},
{
"title": "穿越时空",
"prompt": "如果你能穿越时空,你会去哪个时代?",
"icon": "ri:time-line"
},
{
"title": "量子力学",
"prompt": "解释一下量子力学是什么?",
"icon": "ri:flask-line"
},
{
"title": "人工智能",
"prompt": "介绍一下人工智能的历史",
"icon": "ri:robot-line"
},
{
"title": "深度学习",
"prompt": "讲解一下深度学习是如何工作的?",
"icon": "ri:brain-line"
},
{
"title": "冯诺依曼体系结构",
"prompt": "请举例说明什么是冯诺依曼体系结构?",
"icon": "ri:computer-line"
},
{
"title": "红楼梦情感分析",
"prompt": "请分析《红楼梦》中林黛玉与贾宝玉的情感关系。",
"icon": "ri:book-2-line"
},
{
"title": "100米短跑训练",
"prompt": "如何训练才能提高100米短跑成绩",
"icon": "ri:run-line"
},
{
"title": "北京旅游攻略",
"prompt": "请推荐一份适合初次来中国的外国人的北京旅游攻略。",
"icon": "ri:road-map-line"
},
{
"title": "低GI饮食",
"prompt": "什么是低GI饮食这种饮食有哪些好处",
"icon": "ri:restaurant-2-line",
"iconColor": "text-orange-500"
},
{
"title": "全球环境问题",
"prompt": "请列出目前全球主要面临的三大环境问题,并简单阐述其影响和应对措施。",
"icon": "ri:earth-line"
},
{
"title": "提高社交影响力",
"prompt": "在社交场合,如何提高自己的感染力和影响力?",
"icon": "ri:team-line"
},
{
"title": "地中海地理特征",
"prompt": "请描述一下地中海的地理特征,以及这些特征对于古代世界的影响。",
"icon": "ri:map-pin-line"
},
{
"title": "《肖申克的救赎》影评",
"prompt": "请评价电影《肖申克的救赎》的剧情、角色塑造和拍摄手法。",
"icon": "ri:film-line"
},
{
"title": "苹果公司成功分析",
"prompt": "为什么苹果公司的产品总是比其他公司的产品更受欢迎?请从市场策略、产品设计、品牌形象等方面进行分析。",
"icon": "ri:apple-line"
},
{
"title": "健康饮食计划",
"prompt": "如何制定一份健康的饮食计划?",
"icon": "ri:heart-line"
},
{
"title": "编程学习指南",
"prompt": "怎样学习编程?",
"icon": "ri:code-line"
},
{
"title": "巴厘岛旅游景点",
"prompt": "在巴厘岛旅游有哪些值得参观的景点?",
"icon": "ri:map-pin-2-line"
},
{
"title": "处理亲密关系分歧",
"prompt": "如何处理亲密关系中的分歧?",
"icon": "ri:heart-2-line"
},
{
"title": "费马大定理证明",
"prompt": "如何证明费马大定理?",
"icon": "ri:function-line"
},
{
"title": "吸烟相关疾病预防",
"prompt": "长期吸烟引起的疾病有哪些?应该如何预防?",
"icon": "ri:lungs-line"
},
{
"title": "克服拖延症",
"prompt": "如何克服拖延症?",
"icon": "ri:time-line"
},
{
"title": "减少家庭垃圾",
"prompt": "如何减少家庭垃圾产生?",
"icon": "ri:recycle-line"
},
{
"title": "股票价值评估",
"prompt": "如何评估股票的价值?",
"icon": "ri:stock-line"
},
{
"title": "自信的社交表现",
"prompt": "如何在社交场合自信地表现自己?",
"icon": "ri:team-line"
},
{
"title": "推荐科幻电影",
"prompt": "给我一个最近评分不错的科幻电影的名字和简介",
"icon": "ri:movie-line"
},
{
"title": "英文翻译校对",
"prompt": "将下面这句英文翻译成中文并纠正其中的语法错误:'Me and him goes to the store yesterday.'",
"icon": "ri:translate-2",
"iconColor": "text-orange-500"
},
{
"title": "科技类大市值股票",
"prompt": "给我一些市值超过1000亿美元的科技类股票",
"icon": "ri:bar-chart-box-line"
},
{
"title": "商品销售量预测",
"prompt": "基于历史销售数据,预测下周某商品的销售量。",
"icon": "ri:line-chart-line",
"iconColor": "text-cyan-500"
},
{
"title": "思念诗歌创作",
"prompt": "请用七言绝句写一首表达思念之情的诗歌。",
"icon": "ri:quill-pen-line"
},
{
"title": "情侣约会餐厅推荐",
"prompt": "给我一个适合情侣约会的餐厅的名字和地址。",
"icon": "ri:restaurant-2-line"
},
{
"title": "西班牙旅游行程规划",
"prompt": "我计划去西班牙旅游请帮我安排一个10天的行程。",
"icon": "ri:suitcase-3-line",
"iconColor": "text-orange-500"
},
{
"title": "电影分类归类",
"prompt": "将电影从爱情片、动作片和恐怖片三种分类中分别归类。",
"icon": "ri:film-line"
},
{
"title": "豆腐美食推荐",
"prompt": "推荐一道以豆腐为主要原料的美食,附上制作方法。",
"icon": "ri:restaurant-line"
},
{
"title": "流行华语歌曲推荐",
"prompt": "推荐最近流行的三首华语歌曲,并简要介绍它们的风格和歌词主题。",
"icon": "ri:music-line"
},
{
"title": "减少塑料污染生活指南",
"prompt": "请提供三条减少塑料污染的生活指南。",
"icon": "ri:leaf-line"
},
{
"title": "团队合作处理矛盾",
"prompt": "如何在团队合作中处理与同事之间的矛盾?",
"icon": "ri:team-line"
},
{
"title": "前景股票投资",
"prompt": "你认为现在买入哪些股票比较有前景?",
"icon": "ri:stock-line"
},
{
"title": "科幻片推荐",
"prompt": "你能否给我推荐一部最近上映的好看的科幻片?",
"icon": "ri:film-line"
},
{
"title": "三亚旅游攻略",
"prompt": "希望去三亚旅游,你能提供一份详细的旅游攻略吗?",
"icon": "ri:suitcase-2-line",
"iconColor": "text-orange-500"
},
{
"title": "意大利面烹饪技巧",
"prompt": "我想学做意大利面,你有什么简单易学的做法推荐吗?",
"icon": "ri:restaurant-line"
},
{
"title": "缓解焦虑的方法",
"prompt": "我感到很紧张,有什么方法能够缓解焦虑吗?",
"icon": "ri:heart-pulse-line"
},
{
"title": "电商平台投诉处理",
"prompt": "我在某电商平台购买的商品质量不佳,该如何向平台进行投诉处理?",
"icon": "ri:feedback-line"
},
{
"title": "有效学外语的方法",
"prompt": "你觉得学外语最有效的方法是什么?",
"icon": "ri:translate-2"
},
{
"title": "职场发展建议",
"prompt": "我正在寻找新的工作机会,有哪些职业领域前景较好?",
"icon": "ri:briefcase-line",
"iconColor": "text-cyan-500"
},
{
"title": "日本旅游攻略",
"prompt": "提供至少三个去日本旅游必去的景点,并描述其特色和适合的旅游时间。",
"icon": "ri:map-pin-line"
},
{
"title": "提高保险销售业绩",
"prompt": "如何提高保险销售员的业绩?",
"icon": "ri:money-dollar-box-line"
},
{
"title": "公司网站改版建议",
"prompt": "公司网站需要进行改版,请列举至少五个需要更新的页面元素并说明更新的理由。",
"icon": "ri:layout-5-line"
},
{
"title": "印度首都查询",
"prompt": "请问印度的首都是哪里?",
"icon": "ri:flag-line"
},
{
"title": "红旗渠修建历史",
"prompt": "请问红旗渠修建的时间和地点分别是什么?",
"icon": "ri:history-line"
},
{
"title": "DNA结构与功能",
"prompt": "请简要介绍一下DNA的结构及其功能。",
"icon": "ri:dna-line"
},
{
"title": "GDP定义与计算",
"prompt": "请问什么是GDP如何计算GDP",
"icon": "ri:bar-chart-2-line"
},
{
"title": "原子核组成",
"prompt": "请问原子核由哪些粒子组成?它们各自的电荷和质量分别是多少?",
"icon": "ri:leaf-line"
},
{
"title": "莫扎特代表作",
"prompt": "请问莫扎特的代表作有哪些?",
"icon": "ri:music-2-line"
},
{
"title": "汉字词源",
"prompt": "请问“汉字”这个词最早出现的时间和在哪本书中出现的?",
"icon": "ri:book-line",
"iconColor": "text-orange-500"
},
{
"title": "全运会历史",
"prompt": "请问全运会是哪年开始举办的?每隔几年举办一次?",
"icon": "ri:football-line"
},
{
"title": "石油用途",
"prompt": "请问石油的主要用途有哪些?",
"icon": "ri:oil-line"
},
{
"title": "心脏起搏器介绍",
"prompt": "请简要介绍一下心脏起搏器的原理和使用方法。",
"icon": "ri:heart-2-line",
"iconColor": "text-cyan-500"
},
{
"title": "观众情感分析",
"prompt": "这部电影的观众反应如何?",
"icon": "ri:emotion-laugh-line"
},
{
"title": "沙滩美景短文",
"prompt": "请写出一篇描述橙色阳光下沙滩美景的短文。",
"icon": "ri:sun-line",
"iconColor": "text-orange-500"
},
{
"title": "亚马逊财报数据查询",
"prompt": "亚马逊公司的年度财报数据是多少?",
"icon": "ri:money-dollar-box-line"
},
{
"title": "苹果新产品新闻",
"prompt": "请问最近有关于苹果公司新发布产品的新闻吗?",
"icon": "ri:apple-line"
},
{
"title": "一加与华为手机性能对比",
"prompt": "请比较一加手机和华为手机的性能差异。",
"icon": "ri:smartphone-line"
},
{
"title": "文章主要观点提取",
"prompt": "请从这篇文章中提取出主要观点。",
"icon": "ri:article-line"
},
{
"title": "用户意图分类",
"prompt": "用户输入“我想要预定机票”,它的意图是什么?",
"icon": "ri:question-line"
},
{
"title": "文章可读性修改",
"prompt": "请编辑这篇文章,使得它更易读。",
"icon": "ri:edit-line"
},
{
"title": "星期推理",
"prompt": "如果今天是星期三,那么后天是星期几?",
"icon": "ri:calendar-line",
"iconColor": "text-cyan-500"
},
{
"title": "微软创始人查询",
"prompt": "谁创办了微软公司?",
"icon": "ri:building-4-line"
},
{
"title": "电影类型分类",
"prompt": "这个电影是哪个类型的?",
"icon": "ri:film-line"
},
{
"title": "乐器描述",
"prompt": "描述一下你最喜欢的乐器。",
"icon": "ri:music-line",
"iconColor": "text-orange-500"
},
{
"title": "句子改写",
"prompt": "请改写这句话:“天空飘着几朵云彩。”",
"icon": "ri:edit-2-line"
},
{
"title": "书籍对比",
"prompt": "这本书和那本书有什么区别?",
"icon": "ri:book-line"
},
{
"title": "自然风景描写",
"prompt": "写一段自然风景的描写。",
"icon": "ri:landscape-line"
},
{
"title": "音乐年代分类",
"prompt": "这首歌曲属于哪个年代的音乐?",
"icon": "ri:music-2-line"
},
{
"title": "餐厅美食对比",
"prompt": "这家餐厅和那家餐厅哪家更好吃?",
"icon": "ri:restaurant-line"
},
{
"title": "电影喜好",
"prompt": "把这句话翻译成英文:“我喜欢看电影,尤其是科幻电影。”",
"icon": "ri:movie-line"
},
{
"title": "理想度假胜地描述",
"prompt": "描述一下你理想中的度假胜地。",
"icon": "ri:tree-line",
"iconColor": "text-orange-500"
},
{
"title": "动物分类",
"prompt": "这个动物属于哪个门类?",
"icon": "ri:bug-line"
},
{
"title": "新闻摘要生成",
"prompt": "请问如何利用 GPT-3.5 生成一篇 100 字左右的新闻摘要?",
"icon": "ri:newspaper-line"
},
{
"title": "自动翻译实现",
"prompt": "请问如何让 GPT-3.5 实现从中文到英文的自动翻译?",
"icon": "ri:translate"
},
{
"title": "全球医疗保健评价",
"prompt": "你如何评价当前全球范围内的医疗保健体系?",
"icon": "ri:stethoscope-line"
},
{
"title": "文化多样性保护",
"prompt": "请问有哪些国家在法律层面上保护本国的文化多样性?",
"icon": "ri:global-line"
},
{
"title": "新能源普及国家",
"prompt": "现今世界上使用新能源最为普及的国家是哪些?",
"icon": "ri:flashlight-line"
},
{
"title": "股市走势预测",
"prompt": "你认为全球股市未来一个季度会走势如何?",
"icon": "ri:line-chart-line",
"iconColor": "text-orange-500"
},
{
"title": "前沿科技研究",
"prompt": "请列举一些目前全球前沿的科技研究领域。",
"icon": "ri:rocket-line"
},
{
"title": "社交媒体影响",
"prompt": "社交媒体对年轻人的影响有哪些?",
"icon": "ri:chat-3-line"
},
{
"title": "电商平台市场份额",
"prompt": "当前哪些电商平台在全球拥有最大的市场份额?",
"icon": "ri:shopping-cart-line",
"iconColor": "text-cyan-500"
},
{
"title": "气候变化影响",
"prompt": "气候变化对世界各地造成了哪些影响?",
"icon": "ri:sun-cloudy-line"
},
{
"title": "全球顶尖大学排名",
"prompt": "请问哪些国家拥有全球最顶尖的大学排名?",
"icon": "ri:school-line",
"iconColor": "text-orange-500"
},
{
"title": "手机发明者",
"prompt": "手机是谁发明的?",
"icon": "ri:smartphone-line"
},
{
"title": "旅行故事创作",
"prompt": "给我写一个关于旅行的故事。",
"icon": "ri:suitcase-3-line"
},
{
"title": "文章情感分析",
"prompt": "这篇文章中的情感倾向是积极、消极还是中性?",
"icon": "ri:emotion-line"
},
{
"title": "拼写错误纠正",
"prompt": "句子中的哪个单词拼写有误:“昨天我去了餐馆,品尝了他们的招牌菜。”",
"icon": "ri:check-line"
},
{
"title": "文章摘要生成",
"prompt": "请为这篇长文章生成一段简要的摘要。",
"icon": "ri:file-text-line"
},
{
"title": "任务执行指令",
"prompt": "请告诉我现在怎么做。",
"icon": "ri:task-line",
"iconColor": "text-orange-500"
},
{
"title": "明朝社会阶层研究",
"prompt": "针对明朝时期的社会阶层结构,你能列出几种不同的人群并描述他们的特征吗?",
"icon": "ri:book-line",
"iconColor": "text-brown-500"
},
{
"title": "物种区别解释",
"prompt": "两个相似物种的区别在哪里?请用一种易于理解的方式解释。",
"icon": "ri:leaf-line"
},
{
"title": "政治参与度分析",
"prompt": "哪些因素影响政治参与度?你认为如何激发公民参与政治?",
"icon": "ri:government-line"
},
{
"title": "情感分析技术",
"prompt": "如何利用自然语言处理技术进行情感分析?您可以列举一些常见的情感分析算法和应用场景吗?",
"icon": "ri:emotion-line"
},
{
"title": "经济发展水平衡量",
"prompt": "如何衡量一个国家的经济发展水平?您如何评估不同国家之间的贸易关系?",
"icon": "ri:money-dollar-circle-line"
},
{
"title": "机器学习简介",
"prompt": "讲述一下什么是机器学习,以及它在现代计算机科学中扮演的角色。",
"icon": "ri:robot-line"
},
{
"title": "气候变化影响",
"prompt": "近年来,气候变化对我们的环境造成了哪些影响?未来还可能会引起哪些灾难?",
"icon": "ri:sun-cloudy-line"
},
{
"title": "创新教育方法",
"prompt": "教师应该如何培养学生的创新思维和实践能力?您认为有效的教育方法是什么?",
"icon": "ri:lightbulb-line",
"iconColor": "text-orange-500"
},
{
"title": "学习心理素质",
"prompt": "学习一门新技能需要哪些心理素质?如何在学习过程中保持积极的情绪状态?",
"icon": "ri:psychotherapy-line"
},
{
"title": "未来科技趋势",
"prompt": "未来科技发展的趋势是什么?您认为会有哪些领域会得到革命性的改变?",
"icon": "ri:rocket-line"
},
{
"title": "电影推荐",
"prompt": "根据我的口味推荐一部近期上映的电影。",
"icon": "ri:film-line"
},
{
"title": "手机产品比较",
"prompt": "请分析一下 iPhone 和 Android 手机的优缺点,说明它们适合不同的用户群体。",
"icon": "ri:smartphone-line"
},
{
"title": "新闻头条创作",
"prompt": "请为明天的头条新闻写一个简短但有吸引力的标题,并提出三个相关问题。",
"icon": "ri:newspaper-line",
"iconColor": "text-orange-500"
},
{
"title": "市场零食品牌分析",
"prompt": "请列举五种最受欢迎的零食品牌,并分析其在市场上的竞争优势。",
"icon": "ri:shopping-bag-line"
},
{
"title": "自然之美短文",
"prompt": "请根据以下关键词写一篇题为“自然之美”的 300 字左右的短文:山水、湖泊、森林、鸟儿、日出日落。",
"icon": "ri:palette-line"
},
{
"title": "英文文本编辑",
"prompt": "翻译以下这段英文同时对其进行适当的调整和编辑He was lay down.",
"icon": "ri:edit-line"
},
{
"title": "近期电影推荐",
"prompt": "可以给我推荐几部最近比较值得观看的电影吗?",
"icon": "ri:film-line"
},
{
"title": "马克思主义知识问答",
"prompt": "马克思主义的基本原理是什么?",
"icon": "ri:questionnaire-line"
},
{
"title": "北京旅游攻略",
"prompt": "如果想去北京旅游,有哪些必去的景点和美食呢?",
"icon": "ri:road-map-line",
"iconColor": "text-orange-500"
},
{
"title": "经济形势分析",
"prompt": "分析一下目前国内外经济形势,对未来的发展有何预测?",
"icon": "ri:line-chart-line"
},
{
"title": "文章情感分类",
"prompt": "这篇文章是正面的还是负面的?",
"icon": "ri:emotion-line"
},
{
"title": "写作效率提升方法",
"prompt": "有哪些方法可以提高写作效率?",
"icon": "ri:keyboard-box-line"
},
{
"title": "电子书与纸质书对比",
"prompt": "阅读电子书和纸质书有什么区别?",
"icon": "ri:book-2-line"
},
{
"title": "论文语法修改",
"prompt": "请帮我修改这篇论文中的语法错误。",
"icon": "ri:file-edit-line"
},
{
"title": "人工智能知识查询",
"prompt": "什么是人工智能?",
"icon": "ri:robot-line"
},
{
"title": "实体识别",
"prompt": "在这段文字中,'苹果'指的是手机品牌还是水果?",
"icon": "ri:barcode-box-line"
},
{
"title": "文章主题分类",
"prompt": "这篇文章的主题是什么?",
"icon": "ri:layout-line",
"iconColor": "text-orange-500"
},
{
"title": "文章摘要生成",
"prompt": "请用一句话概括这篇文章的核心内容。",
"icon": "ri:file-text-line"
},
{
"title": "新上映电影推荐",
"prompt": "有哪些值得一看的新上映电影?",
"icon": "ri:movie-line"
},
{
"title": "欧洲杯赛程",
"prompt": "请列出近期欧洲杯足球赛程表。",
"icon": "ri:trophy-line",
"iconColor": "text-gold-500"
},
{
"title": "健康饮食方案",
"prompt": "有哪些适合控制体重的健康饮食方案?",
"icon": "ri:restaurant-line",
"iconColor": "text-orange-500"
},
{
"title": "日本旅游攻略",
"prompt": "如果我想去日本旅游,应该怎样规划我的行程和预算?",
"icon": "ri:suitcase-line"
},
{
"title": "最新科技新闻",
"prompt": "有哪些最近的科技进展值得关注?",
"icon": "ri:news-line"
},
{
"title": "编程语言选择",
"prompt": "当你需要开发一个新项目时,该如何选择合适的编程语言?",
"icon": "ri:code-box-line"
},
{
"title": "健康饮食搭配",
"prompt": "请问在平衡健康饮食方面,应该怎样搭配膳食结构?",
"icon": "ri:restaurant-2-line"
},
{
"title": "科技公司伦理标准",
"prompt": "微软、谷歌等科技公司是否有明确的伦理标准?如果有,请简要列举这些标准。",
"icon": "ri:shield-check-line"
},
{
"title": "机器人研究方向",
"prompt": "机器人研究领域都包括哪些方向?",
"icon": "ri:robot-line"
},
{
"title": "气候变化影响",
"prompt": "你认为气候变化对人类有哪些不利影响?",
"icon": "ri:cloud-windy-line"
}
]

BIN
chat/src/assets/fail.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

BIN
chat/src/assets/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
chat/src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

BIN
chat/src/assets/market.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

BIN
chat/src/assets/reset.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
chat/src/assets/wechat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 662 B

BIN
chat/src/assets/wxpay.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@@ -0,0 +1,101 @@
<script setup lang="ts">
import { useAuthStore, useGlobalStoreWithOut } from '@/store'
import 'md-editor-v3/lib/preview.css'
import { ref, watch } from 'vue'
interface Props {
visible: boolean
}
const props = defineProps<Props>()
const authStore = useAuthStore()
const useGlobalStore = useGlobalStoreWithOut()
const countdown = ref(15) // 倒计时 15 秒
const isCountdownFinished = ref(false) // 倒计时结束标志
function startCountdown() {
const interval = setInterval(() => {
if (countdown.value > 0) {
countdown.value -= 1
} else {
isCountdownFinished.value = true // 倒计时结束
clearInterval(interval) // 清除定时器
}
}, 1000)
}
function handleAgree() {
if (isCountdownFinished.value) {
useGlobalStore.UpdateBadWordsDialog(false) // 关闭用户协议弹窗
countdown.value = 15
isCountdownFinished.value = false
}
}
// onMounted(() => {
// startCountdown(); // 组件挂载时启动倒计时
// });
// 监听 props.visible 的变化,当其变为 true 时重新启动倒计时
watch(
() => props.visible,
newVal => {
if (newVal) {
startCountdown()
}
},
{ immediate: true } // 添加 immediate 选项,初始化时立即执行
)
// 初始启动一次倒计时
if (props.visible) {
startCountdown()
}
// onUnmounted(() => {
// // countdown.value = 15;
// isCountdownFinished.value = false; // 重置状态
// });
</script>
<template>
<div
v-if="props.visible"
class="fixed inset-0 z-50 px-2 flex items-center justify-center bg-black bg-opacity-50"
>
<div
class="bg-white dark:bg-gray-900 p-4 rounded-lg shadow-lg w-full max-w-3xl max-h-[80vh] flex flex-col relative"
>
<!-- 显示用户协议标题 -->
<div class="flex justify-between items-center mb-3">
<span class="text-xl">合理合规须知</span>
</div>
<!-- 直接显示用户协议的 HTML 内容 -->
<div class="flex-1 overflow-y-auto custom-scrollbar">
<!-- <div
v-html="globalConfig.agreementInfo"
class="dark:bg-gray-900 p-4"
></div> -->
<p>请合理合规使用请勿咨询敏感信息或使用敏感词生成图片</p>
<p>
多次触发平台风控将记录账号/IP等信息并禁止使用保留向有关部门提交相关记录的权利
</p>
</div>
<!-- 倒计时和同意按钮 -->
<div class="flex justify-end mt-3">
<button
:disabled="!isCountdownFinished"
@click="handleAgree"
class="px-4 py-2 shadow-sm bg-primary-600 text-white rounded-md hover:bg-primary-500 disabled:bg-gray-400"
>
<span v-if="isCountdownFinished">已知晓</span>
<span v-else>请等待 {{ countdown }} </span>
</button>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,92 @@
<template>
<div class="p-6 space-y-4 dark:bg-gray-800">
<h1 class="text-xl font-bold dark:text-white">关闭按钮演示</h1>
<div class="flex items-center space-x-6">
<div class="space-y-2">
<p class="text-sm dark:text-gray-300">小尺寸</p>
<button class="btn-close btn-close-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="space-y-2">
<p class="text-sm dark:text-gray-300">中尺寸</p>
<button class="btn-close btn-close-md">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<div class="space-y-2">
<p class="text-sm dark:text-gray-300">大尺寸</p>
<button class="btn-close btn-close-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
class="w-6 h-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<div class="p-4 border rounded-lg dark:border-gray-700 relative">
<h2 class="text-lg font-semibold dark:text-white">模态框示例</h2>
<p class="mt-2 dark:text-gray-300">这是一个带有关闭按钮的模态框示例</p>
<button class="btn-close btn-close-md absolute top-3 right-3">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
</template>
<script setup>
// 无需特别的逻辑
</script>

View File

@@ -0,0 +1,99 @@
<template>
<Teleport to="body" :disabled="!visible">
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="transform opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="transform opacity-0"
>
<div v-if="visible" class="fixed inset-0 z-[9999]">
<!-- 遮罩层 -->
<div class="absolute inset-0 bg-black/50" @click="handleCancel"></div>
<!-- 对话框 -->
<div class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
<div
class="w-[400px] bg-white dark:bg-[#24272e] rounded-lg shadow-lg overflow-hidden"
@click.stop
>
<!-- 标题 -->
<div class="p-4 border-b border-neutral-200 dark:border-neutral-700">
<h3 class="text-lg font-medium text-neutral-900 dark:text-neutral-100">
{{ options.title }}
</h3>
</div>
<!-- 内容 -->
<div class="p-4 text-neutral-700 dark:text-neutral-300">
{{ options.content }}
</div>
<!-- 按钮 -->
<div class="flex justify-end gap-2 px-4 py-3">
<button
class="px-4 py-2 text-sm rounded-md border border-neutral-200 dark:border-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-700 text-neutral-700 dark:text-neutral-300 transition-colors"
@click="handleCancel"
>
{{ options.negativeText }}
</button>
<button
class="px-4 py-2 text-sm rounded-md bg-primary-500 hover:bg-primary-600 text-white transition-colors"
@click="handleConfirm"
>
{{ options.positiveText }}
</button>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import type { DialogOptions } from '@/utils/dialog'
import { ref } from 'vue'
const visible = ref(false)
const resolvePromise: any = ref(null)
const options = ref<DialogOptions>({
title: '',
content: '',
positiveText: '确认',
negativeText: '取消',
})
const showDialog = (dialogOptions: DialogOptions) => {
options.value = {
...options.value,
...dialogOptions,
}
visible.value = true
return new Promise(resolve => {
resolvePromise.value = resolve
})
}
const handleConfirm = async () => {
try {
if (options.value.onPositiveClick) {
await options.value.onPositiveClick()
}
visible.value = false
resolvePromise.value(true)
} catch (e) {
resolvePromise.value(false)
}
}
const handleCancel = () => {
visible.value = false
resolvePromise.value(false)
}
defineExpose({
showDialog,
})
</script>

View File

@@ -0,0 +1,101 @@
<script setup lang="ts">
import { useAuthStore, useGlobalStoreWithOut } from '@/store'
import 'md-editor-v3/lib/preview.css'
import { ref, watch } from 'vue'
interface Props {
visible: boolean
}
const props = defineProps<Props>()
const authStore = useAuthStore()
const useGlobalStore = useGlobalStoreWithOut()
const countdown = ref(15) // 倒计时 15 秒
const isCountdownFinished = ref(false) // 倒计时结束标志
function startCountdown() {
const interval = setInterval(() => {
if (countdown.value > 0) {
countdown.value -= 1
} else {
isCountdownFinished.value = true // 倒计时结束
clearInterval(interval) // 清除定时器
}
}, 1000)
}
function handleAgree() {
if (isCountdownFinished.value) {
useGlobalStore.UpdateBadWordsDialog(false) // 关闭用户协议弹窗
countdown.value = 15
isCountdownFinished.value = false
}
}
// onMounted(() => {
// startCountdown(); // 组件挂载时启动倒计时
// });
// 监听 props.visible 的变化,当其变为 true 时重新启动倒计时
watch(
() => props.visible,
newVal => {
if (newVal) {
startCountdown()
}
},
{ immediate: true } // 添加 immediate 选项,初始化时立即执行
)
// 初始启动一次倒计时
if (props.visible) {
startCountdown()
}
// onUnmounted(() => {
// // countdown.value = 15;
// isCountdownFinished.value = false; // 重置状态
// });
</script>
<template>
<div
v-if="props.visible"
class="fixed inset-0 z-[10001] px-2 flex items-center justify-center bg-black bg-opacity-50"
>
<div
class="bg-white dark:bg-gray-900 p-4 rounded-lg shadow-lg w-full max-w-3xl max-h-[80vh] flex flex-col relative"
>
<!-- 显示用户协议标题 -->
<div class="flex justify-between items-center mb-3">
<span class="text-xl">合理合规须知</span>
</div>
<!-- 直接显示用户协议的 HTML 内容 -->
<div class="flex-1 overflow-y-auto custom-scrollbar">
<!-- <div
v-html="globalConfig.agreementInfo"
class="dark:bg-gray-900 p-4"
></div> -->
<p>请合理合规使用请勿咨询敏感信息或使用敏感词生成图片</p>
<p>
多次触发平台风控将记录账号/IP等信息并禁止使用保留向有关部门提交相关记录的权利
</p>
</div>
<!-- 倒计时和同意按钮 -->
<div class="flex justify-end mt-3">
<button
:disabled="!isCountdownFinished"
@click="handleAgree"
class="px-4 py-2 shadow-sm bg-primary-600 text-white rounded-md hover:bg-primary-500 disabled:bg-gray-400"
>
<span v-if="isCountdownFinished">已知晓</span>
<span v-else>请等待 {{ countdown }} </span>
</button>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,327 @@
<script setup lang="ts">
import { createShare } from '@/api/share'
import type { ResData } from '@/api/types'
import { useBasicLayout } from '@/hooks/useBasicLayout'
import { useGlobalStoreWithOut } from '@/store'
import { message } from '@/utils/message'
import { html } from '@codemirror/lang-html'
import { EditorState } from '@codemirror/state'
import { oneDark } from '@codemirror/theme-one-dark'
import { Close } from '@icon-park/vue-next'
import { EditorView, basicSetup } from 'codemirror'
import { computed, nextTick, onMounted, onUnmounted, ref, watch, watchEffect } from 'vue'
interface Props {
visible: boolean
html?: string
editable?: boolean
}
const props = defineProps<Props>()
const emit = defineEmits(['update:visible', 'update:html'])
const globalStore = useGlobalStoreWithOut()
const ms = message()
const htmlPreviewRef = ref<HTMLIFrameElement | null>(null)
const localEditableText = ref(props.html || '')
const { isMobile } = useBasicLayout()
const editorContainerRef = ref<HTMLDivElement | null>(null)
const isDarkMode = computed(() => document.documentElement.classList.contains('dark'))
// CodeMirror 编辑器实例
let editor: EditorView | null = null
// 当props.html变化时更新本地编辑文本和编辑器
watchEffect(() => {
if (props.visible) {
// Update local ref first
if (props.html && props.html !== localEditableText.value) {
localEditableText.value = props.html
}
// Update editor content if editor exists and content differs
if (editor) {
const currentContent = editor.state.doc.toString()
if (localEditableText.value !== currentContent) {
editor.dispatch({
changes: {
from: 0,
to: editor.state.doc.length,
insert: localEditableText.value || '',
},
})
}
}
}
})
// 使用 watchEffect 来更新预览,当 localEditableText 变化时
watchEffect(() => {
if (props.visible) {
// 只在可见时更新预览
updatePreview()
}
})
// 初始化编辑器
const initializeEditor = () => {
if (!editorContainerRef.value || editor) return // 防止重复初始化
const extensions = [
basicSetup,
html(),
EditorView.updateListener.of(update => {
if (update.docChanged) {
const newContent = update.state.doc.toString()
localEditableText.value = newContent
emit('update:html', newContent) // Optional: emit update immediately
}
}),
]
if (isDarkMode.value) {
extensions.push(oneDark)
}
const state = EditorState.create({
doc: localEditableText.value || '',
extensions,
})
editor = new EditorView({
state,
parent: editorContainerRef.value,
})
}
// 预览更新逻辑 (保持不变或根据需要调整)
const updatePreview = () => {
if (htmlPreviewRef.value) {
// 更新 iframe 的 srcDoc 更为推荐,避免潜在的 XSS
htmlPreviewRef.value.srcdoc = localEditableText.value
/* 或者保持原来的方式,如果需要执行脚本等
const iframeDocument = htmlPreviewRef.value.contentDocument
if (iframeDocument) {
iframeDocument.open()
iframeDocument.write(localEditableText.value)
iframeDocument.close()
}
*/
}
}
function handleClose() {
// 阻止事件冒泡和默认行为
emit('update:visible', false)
emit('update:html', localEditableText.value)
globalStore.updateHtmlDialog(false)
return false
}
const handleCopy = async () => {
try {
const success = await copyToClipboard(localEditableText.value)
if (success) {
ms.success('内容已复制到剪贴板')
} else {
// 复制失败时,提示用户手动复制
ms.info('复制失败,请手动复制文本框中的内容')
}
} catch (err) {
ms.error('复制失败')
}
}
const handleShare = async () => {
try {
const res: ResData = await createShare({
htmlContent: localEditableText.value,
})
const shareCode = res.data.shareCode
const success = await copyToClipboard(shareCode)
if (success) {
ms.success('分享链接已复制到剪贴板')
} else {
localEditableText.value = shareCode
ms.info('复制失败,分享链接已显示在文本框中,请手动复制')
}
} catch (err) {
ms.error('分享失败')
}
}
// 兼容性更好的剪贴板复制方法
const copyToClipboard = async (text: string): Promise<boolean> => {
try {
await navigator.clipboard.writeText(text)
return true
} catch (err) {
return false
}
}
// 组件挂载和卸载逻辑
onMounted(() => {
// 如果初始可见,则初始化编辑器
if (props.visible) {
nextTick(initializeEditor)
}
})
onUnmounted(() => {
if (editor) {
editor.destroy()
editor = null
}
})
// 监听可见性变化来创建/销毁编辑器
watch(
() => props.visible,
newVal => {
if (newVal) {
// 弹窗变为可见延迟初始化编辑器以确保DOM可用
nextTick(() => {
if (!editor && editorContainerRef.value) {
initializeEditor()
}
// 确保预览是最新的
updatePreview()
})
} else {
// 弹窗关闭时无需立即销毁编辑器onUnmounted 会处理
// 但可以考虑保存状态等操作
emit('update:html', localEditableText.value) // 确保关闭时内容已更新
}
}
)
</script>
<template>
<teleport to="body">
<div
v-if="props.visible"
class="fixed inset-0 z-50 flex items-center justify-center html-modal-container"
>
<div class="fixed inset-0 bg-black bg-opacity-50" @click.stop="handleClose"></div>
<Close
class="absolute top-3 right-3 cursor-pointer z-30"
size="18"
@click.stop.prevent="handleClose"
/>
<div
class="relative bg-white dark:bg-gray-900 w-full h-full p-4 z-10"
:class="[isMobile ? 'flex-col' : 'flex']"
@click.stop
>
<!-- 移动端预览区域 -->
<div v-if="isMobile" class="p-2 w-full h-1/2">
<iframe
ref="htmlPreviewRef"
:srcDoc="localEditableText"
class="box-border w-full h-full border rounded-md"
frameborder="0"
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
></iframe>
</div>
<!-- 编辑区域 -->
<div
v-if="props.editable !== false"
class="p-2 flex flex-col"
:class="[isMobile ? 'w-full h-1/2' : 'w-1/4']"
>
<!-- CodeMirror 编辑器容器 -->
<div
ref="editorContainerRef"
class="w-full h-full border rounded-md overflow-hidden dark:border-gray-700 code-editor-container"
></div>
<div class="mt-2 flex justify-end">
<button
@click="handleClose"
class="px-4 py-2 shadow-sm ring-1 ring-inset bg-white ring-gray-300 hover:bg-gray-50 text-gray-900 rounded-md mr-4 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:ring-gray-700 dark:hover:ring-gray-600"
>
取消
</button>
<button
@click="handleCopy"
class="px-4 py-2 shadow-sm bg-primary-600 hover:bg-primary-500 text-white dark rounded-md mr-4"
>
复制
</button>
<button
@click="handleShare"
class="px-4 py-2 shadow-sm bg-primary-600 hover:bg-primary-500 text-white dark rounded-md"
>
分享
</button>
</div>
</div>
<!-- 桌面端预览区域 -->
<div v-if="!isMobile" :class="[props.editable === false ? 'w-full' : 'w-3/4']" class="p-2">
<iframe
ref="htmlPreviewRef"
:srcDoc="localEditableText"
class="box-border w-full h-full border rounded-md"
frameborder="0"
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
></iframe>
</div>
</div>
</div>
</teleport>
</template>
<style scoped>
.fixed {
position: fixed;
-webkit-position: fixed;
}
iframe {
width: 100%;
height: 100%;
border: none;
}
/* CodeMirror编辑器容器样式 (从 PythonDialog.vue 复制并调整) */
.code-editor-container {
height: calc(100% - 40px); /* Adjust height based on button container */
}
.code-editor-container :deep(.cm-editor) {
height: 100% !important;
font-family: 'Fira Code', 'Consolas', 'Monaco', 'Andale Mono', 'Ubuntu Mono', monospace;
font-size: 14px;
}
.code-editor-container :deep(.cm-scroller) {
overflow: auto;
}
.code-editor-container :deep(.cm-gutters) {
border-right: 1px solid #ddd;
}
.code-editor-container :deep(.dark .cm-gutters) {
border-right: 1px solid #444;
background-color: #1e1e1e;
}
.code-editor-container :deep(.cm-activeLineGutter) {
background-color: rgba(0, 0, 0, 0.1);
}
.code-editor-container :deep(.dark .cm-activeLineGutter) {
background-color: rgba(255, 255, 255, 0.1);
}
.code-editor-container :deep(.cm-focused) {
outline: none !important;
}
</style>

View File

@@ -0,0 +1,165 @@
<script setup lang="ts">
import { useBasicLayout } from '@/hooks/useBasicLayout'
import { fetchVerifyIdentityAPI } from '@/api/user'
import { useAuthStore, useGlobalStoreWithOut } from '@/store'
import { Close } from '@icon-park/vue-next'
import { message } from '@/utils/message'
import { computed, ref } from 'vue'
import Vcode from './Login/SliderCaptcha.vue'
defineProps<Props>()
const ms = message()
const { isMobile } = useBasicLayout()
const isShow = ref(false)
const useGlobalStore = useGlobalStoreWithOut()
const authStore = useAuthStore()
const globalConfig = computed(() => authStore.globalConfig)
const identityForm = ref({
name: '',
idCard: '',
})
const rules = {
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
idCard: [{ required: true, message: '请输入身份证号', trigger: 'blur' }],
}
// 使用 ref 来管理全局参数的状态
const agreedToUserAgreement = ref(true) // 读取初始状态并转换为布尔类型
// 点击"用户协议及隐私政策"时,自动同意
function handleClick() {
agreedToUserAgreement.value = true // 设置为同意
}
function handlerSubmit() {
if (agreedToUserAgreement.value === false && globalConfig.value.isAutoOpenAgreement === '1') {
return ms.error(`请阅读并同意《${globalConfig.value.agreementTitle}`)
}
isShow.value = false
fetchVerifyIdentityAPI(identityForm.value).then(res => {
if (res.code === 200) {
ms.success('认证成功')
useGlobalStore.updateIdentityDialog(false)
} else {
}
})
}
interface Props {
visible: boolean
}
</script>
<template>
<div
v-if="visible"
class="fixed inset-0 z-[10001] flex flex-col items-center justify-center bg-black bg-opacity-50 py-6"
>
<div
class="bg-white p-6 rounded-lg shadow-lg w-full max-h-[70vh] flex flex-col dark:bg-gray-900 dark:text-gray-400 relative"
:class="{ 'max-w-[95vw]': isMobile, 'max-w-xl': !isMobile }"
>
<Close
size="18"
class="absolute top-3 right-3 cursor-pointer z-30"
@click="useGlobalStore.updateIdentityDialog(false)"
/>
<div class="flex-1 flex flex-col items-center">
<div
class="flex w-full flex-col h-full justify-center"
:class="isMobile ? 'px-5 py-5' : 'px-10 py-5'"
>
<!-- forget passwd-->
<form
ref="formRef"
:model="identityForm"
:rules="rules"
label-placement="left"
label-width="auto"
require-mark-placement="right-hanging"
>
<div class="sm:mx-auto sm:w-full sm:max-w-sm">
<h2
class="mb-8 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900 dark:text-gray-300"
>
实名认证
</h2>
</div>
<div class="mt-4">
<label
for="username"
class="block text-sm font-medium leading-6 text-gray-900 dark:text-gray-300"
>姓名
</label>
<div class="mt-2">
<input
id="username"
type="text"
v-model="identityForm.name"
placeholder="请输入姓名"
class="block w-full rounded-md border-0 py-2 px-3 text-gray-900 shadow-sm dark:text-gray-300 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-gray-300 sm:text-sm sm:leading-6 dark:bg-gray-800 dark:focus:ring-gray-400"
/>
</div>
</div>
<div class="mt-4">
<label
for="username"
class="block text-sm font-medium leading-6 text-gray-900 dark:text-gray-300"
>身份证号
</label>
<div class="mt-2">
<input
id="username"
type="text"
v-model="identityForm.idCard"
placeholder="请输入身份证号"
class="block w-full rounded-md border-0 py-2 px-3 text-gray-900 shadow-sm dark:text-gray-300 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-gray-300 sm:text-sm sm:leading-6 dark:bg-gray-800 dark:focus:ring-gray-400"
/>
</div>
</div>
<div
v-if="globalConfig.isAutoOpenAgreement === '1'"
class="flex items-center justify-between my-3"
>
<div class="flex items-center">
<input
v-model="agreedToUserAgreement"
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
/>
<p class="ml-1 text-center text-sm text-gray-500 dark:text-gray-400">
已阅读并同意
<a
href="#"
class="font-semibold leading-6 text-primary-600 hover:text-primary-500 dark:text-primary-500 dark:hover:text-primary-600"
@click="handleClick"
>{{ globalConfig.agreementTitle }}</a
>
</p>
</div>
</div>
<div>
<button
@click="isShow = true"
type="submit"
class="flex w-full my-5 justify-center rounded-md bg-primary-500 px-3 py-2 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-primary-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
提交认证
</button>
</div>
</form>
</div>
</div>
</div>
<Vcode :show="isShow" @success="handlerSubmit()" @close="isShow = false" class="bg-red-500" />
</div>
</template>

View File

@@ -0,0 +1,390 @@
<script lang="ts" setup>
import { fetchLoginAPI, fetchSendCode } from '@/api'
import { useBasicLayout } from '@/hooks/useBasicLayout'
import { t } from '@/locales'
import { useAuthStore, useGlobalStoreWithOut } from '@/store'
import { DIALOG_TABS } from '@/store/modules/global'
import { message } from '@/utils/message'
import { computed, ref } from 'vue'
import SliderCaptcha from './SliderCaptcha.vue'
interface Props {
loginMode: 'password' | 'captcha'
}
const props = defineProps<Props>()
const formRef = ref<HTMLFormElement | null>(null)
const ms = message()
const loading = ref(false)
const authStore = useAuthStore()
const lastSendPhoneCodeTime = ref(0)
const { isMobile } = useBasicLayout()
const isShow = ref(false)
const useGlobalStore = useGlobalStoreWithOut()
const globalConfig = computed(() => authStore.globalConfig)
// 验证码登录表单
const captchaForm = ref({
contact: '',
captchaId: null,
code: '',
})
// 密码登录表单
const passwordForm = ref({
username: '',
password: '',
})
// 验证表单
const validateForm = () => {
let hasError = false
// 密码登录表单验证
if (props.loginMode === 'password') {
// 验证用户名
if (!passwordForm.value.username.trim()) {
hasError = true
} else if (passwordForm.value.username.length < 2 || passwordForm.value.username.length > 30) {
hasError = true
}
// 验证密码
if (!passwordForm.value.password.trim()) {
hasError = true
} else if (passwordForm.value.password.length < 6 || passwordForm.value.password.length > 30) {
hasError = true
}
}
// 验证码登录表单验证
else if (props.loginMode === 'captcha') {
// 验证联系方式
if (!captchaForm.value.contact.trim()) {
hasError = true
}
// 验证验证码
if (!captchaForm.value.captchaId) {
hasError = true
}
}
return !hasError
}
// 只验证联系方式,用于发送验证码前的验证
const validateContactOnly = () => {
return captchaForm.value.contact.trim() !== ''
}
const phoneLoginStatus = computed(() => Number(authStore.globalConfig.phoneLoginStatus) === 1)
const emailLoginStatus = computed(() => Number(authStore.globalConfig.emailLoginStatus) === 1)
// 使用 ref 来管理全局参数的状态
const agreedToUserAgreement = ref(true) // 读取初始状态并转换为布尔类型
// 点击"用户协议及隐私政策"时,自动同意
function handleClick() {
agreedToUserAgreement.value = true // 设置为同意
useGlobalStore.updateSettingsDialog(true, DIALOG_TABS.AGREEMENT)
}
const loginTypeText = computed(() => {
if (emailLoginStatus.value && phoneLoginStatus.value) {
return t('login.emailPhone')
} else if (emailLoginStatus.value) {
return t('login.email')
} else if (phoneLoginStatus.value) {
return t('login.phone')
}
return ''
})
const loginEnterType = computed(() => {
if (emailLoginStatus.value && phoneLoginStatus.value) {
return t('login.enterEmailOrPhone')
} else if (emailLoginStatus.value) {
return t('login.enterEmail')
} else if (phoneLoginStatus.value) {
return t('login.enterPhone')
}
return ''
})
// 定时器改变倒计时时间方法
function changeLastSendPhoneCodeTime() {
if (lastSendPhoneCodeTime.value > 0) {
setTimeout(() => {
lastSendPhoneCodeTime.value--
changeLastSendPhoneCodeTime()
}, 1000)
}
}
/* 发送验证码 */
async function handleSendCaptcha() {
isShow.value = false
if (validateContactOnly()) {
// 只验证联系方式
try {
const { contact } = captchaForm.value
// 只传递联系方式(邮箱或手机号)
const params: any = { contact }
let res: any
res = await fetchSendCode(params)
const { success } = res
if (success) {
ms.success(res.data)
// 记录重新发送倒计时
lastSendPhoneCodeTime.value = 60
changeLastSendPhoneCodeTime()
}
} catch (error) {}
}
}
/* 登录处理 */
function handlerSubmit(event: Event) {
event.preventDefault()
if (agreedToUserAgreement.value === false && globalConfig.value.isAutoOpenAgreement === '1') {
return ms.error(`请阅读并同意《${globalConfig.value.agreementTitle}`)
}
if (validateForm()) {
loginAction()
}
}
async function loginAction() {
try {
loading.value = true
// 根据登录模式构建参数
const params: any =
props.loginMode === 'password'
? {
username: passwordForm.value.username,
password: passwordForm.value.password,
}
: {
username: captchaForm.value.contact,
captchaId: captchaForm.value.captchaId,
}
const res: any = await fetchLoginAPI(params)
loading.value = false
const { success } = res
if (!success) return
ms.success(t('login.loginSuccess'))
authStore.setToken(res.data)
authStore.getUserInfo()
authStore.setLoginDialog(false)
} catch (error: any) {
loading.value = false
ms.error(error.message)
}
}
</script>
<template>
<div class="w-full h-full flex flex-col justify-between" :class="isMobile ? 'px-5 ' : 'px-10 '">
<!-- 密码登录表单 -->
<form
v-if="loginMode === 'password'"
ref="formRef"
class="flex flex-col flex-1 justify-between"
@submit="handlerSubmit"
>
<div>
<!-- 用户名输入框 -->
<div class="flex flex-col gap-2">
<label
for="username"
class="block text-sm/6 font-medium text-gray-900 dark:text-gray-300"
>{{ loginTypeText }}</label
>
<div>
<input
id="username"
type="text"
v-model="passwordForm.username"
:placeholder="loginEnterType"
class="input input-lg w-full"
/>
</div>
</div>
<!-- 密码输入框 -->
<div class="mt-6 relative">
<div class="flex flex-col gap-2">
<label
for="password"
class="block text-sm/6 font-medium text-gray-900 dark:text-gray-300"
>{{ t('login.password') }}</label
>
<div>
<input
id="password"
type="password"
v-model="passwordForm.password"
:placeholder="t('login.enterYourPassword')"
class="input input-lg w-full"
/>
</div>
</div>
</div>
<!-- 用户协议 -->
<div class="mt-5" v-if="globalConfig.isAutoOpenAgreement === '1'">
<div class="flex items-center">
<input
id="agreement-password"
v-model="agreedToUserAgreement"
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600 dark:border-gray-700 dark:bg-gray-800"
/>
<p class="ml-2 text-sm text-gray-600 dark:text-gray-400">
登录即代表同意
<a
href="#"
class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
@click="handleClick"
>{{ globalConfig.agreementTitle }}</a
>
</p>
</div>
</div>
</div>
<!-- 登录按钮 -->
<div>
<button
type="submit"
class="btn btn-primary btn-lg w-full rounded-full disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="loading || !passwordForm.username.trim() || !passwordForm.password"
>
<span v-if="loading" class="inline-block mr-2">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
</span>
{{ t('login.loginAccount') }}
</button>
</div>
</form>
<!-- 验证码登录表单 -->
<form
v-if="loginMode === 'captcha'"
ref="formRef"
class="flex flex-col flex-1 justify-between"
@submit="handlerSubmit"
>
<div>
<!-- 联系方式输入框 -->
<div class="flex flex-col gap-2">
<label
for="contact"
class="block text-sm/6 font-medium text-gray-900 dark:text-gray-300"
>{{ loginTypeText }}</label
>
<div>
<input
id="contact"
type="text"
v-model="captchaForm.contact"
:placeholder="t('login.enterContact') + loginTypeText"
class="input input-lg w-full"
/>
</div>
</div>
<!-- 验证码输入框 -->
<div class="mt-6">
<div class="flex flex-col gap-2">
<label
for="captchaId"
class="block text-sm/6 font-medium text-gray-900 dark:text-gray-300"
>验证码</label
>
<div class="relative px-1">
<div class="flex relative">
<input
id="captchaId"
type="text"
v-model="captchaForm.captchaId"
:placeholder="t('login.enterCode')"
class="input input-lg w-full pr-32"
/>
<button
type="button"
class="btn-captcha px-4"
:disabled="loading || lastSendPhoneCodeTime > 0 || !captchaForm.contact.trim()"
@click="isShow = true"
>
<span v-if="loading && lastSendPhoneCodeTime === 0" class="inline-block mr-1">
<div class="animate-spin rounded-full h-3 w-3 border-b-2 border-current"></div>
</span>
{{
lastSendPhoneCodeTime > 0
? `${lastSendPhoneCodeTime}`
: t('login.sendVerificationCode')
}}
</button>
</div>
</div>
</div>
</div>
<!-- 验证码组件 -->
<div class="rounded-lg">
<SliderCaptcha
:show="isShow"
@success="handleSendCaptcha()"
@close="isShow = false"
class="z-[10000]"
/>
</div>
<!-- 用户协议 -->
<div class="mt-5" v-if="globalConfig.isAutoOpenAgreement === '1'">
<div class="flex items-center">
<input
id="agreement-captcha"
v-model="agreedToUserAgreement"
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600 dark:border-gray-700 dark:bg-gray-800"
/>
<p class="ml-2 text-sm text-gray-600 dark:text-gray-400">
登录即代表同意
<a
href="#"
class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
@click="handleClick"
>{{ globalConfig.agreementTitle }}</a
>
</p>
</div>
</div>
</div>
<!-- 登录按钮 -->
<div>
<button
type="submit"
class="btn btn-primary btn-lg w-full rounded-full disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="loading || !captchaForm.contact.trim() || !captchaForm.captchaId"
>
<span v-if="loading" class="inline-block mr-2">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
</span>
验证码登录
</button>
</div>
</form>
</div>
</template>

View File

@@ -0,0 +1,109 @@
<script setup lang="ts">
import { useBasicLayout } from '@/hooks/useBasicLayout'
import { useAuthStore } from '@/store'
import { Close } from '@icon-park/vue-next'
import { computed, onMounted, ref } from 'vue'
import Email from './Email.vue'
import Wechat from './Wechat.vue'
defineProps<Props>()
const authStore = useAuthStore()
const { isMobile } = useBasicLayout()
// 当前登录类型wechat(微信登录), password(密码登录), captcha(验证码登录)
const loginType = ref('wechat')
const emailLoginStatus = computed(() => Number(authStore.globalConfig.emailLoginStatus) === 1)
const wechatRegisterStatus = computed(
() => Number(authStore.globalConfig.wechatRegisterStatus) === 1
)
const phoneLoginStatus = computed(() => Number(authStore.globalConfig.phoneLoginStatus) === 1)
// 自动选择合适的登录方式
onMounted(() => {
setDefaultLoginType()
})
// 根据可用的登录方式设置默认登录类型
function setDefaultLoginType() {
if (wechatRegisterStatus.value) {
loginType.value = 'wechat'
} else if (emailLoginStatus.value || phoneLoginStatus.value) {
loginType.value = 'captcha'
} else {
loginType.value = 'password'
}
}
interface Props {
visible: boolean
}
/* 切换登录类型 */
function changeLoginType(type: string) {
loginType.value = type
}
</script>
<template>
<div
v-if="visible"
class="fixed inset-0 z-50 flex flex-col items-center justify-center bg-black bg-opacity-50"
>
<div
class="bg-white py-12 rounded-xl shadow-lg w-full h-[32rem] flex flex-col dark:bg-gray-900 dark:text-gray-300 relative"
:class="{ 'w-[98vw] px-4': isMobile, 'max-w-xl px-8': !isMobile }"
>
<button
@click="authStore.setLoginDialog(false)"
class="btn-icon btn-sm absolute top-4 right-4 z-30 text-gray-700 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
>
<Close theme="outline" size="18" />
</button>
<div class="flex-1 flex flex-col items-center justify-center">
<!-- 登录类型切换栏 -->
<div
class="w-full flex justify-center mb-10"
:class="{ 'px-5': isMobile, 'px-10': !isMobile }"
>
<div class="tab-group tab-group-default dark:bg-gray-800">
<button
v-if="wechatRegisterStatus"
@click="changeLoginType('wechat')"
class="tab tab-lg"
:class="{ 'tab-active': loginType === 'wechat', 'px-0': isMobile }"
>
微信登录
</button>
<button
v-if="emailLoginStatus || phoneLoginStatus"
@click="changeLoginType('captcha')"
class="tab tab-lg"
:class="{ 'tab-active': loginType === 'captcha', 'px-0': isMobile }"
>
验证码登录
</button>
<button
@click="changeLoginType('password')"
class="tab tab-lg"
:class="{ 'tab-active': loginType === 'password', 'px-0': isMobile }"
>
密码登录
</button>
</div>
</div>
<!-- 登录组件区域 -->
<div class="w-full flex-1 flex flex-col overflow-hidden">
<Wechat v-if="loginType === 'wechat'" @changeLoginType="changeLoginType" />
<Email
v-else
:login-mode="loginType === 'password' ? 'password' : 'captcha'"
@changeLoginType="changeLoginType"
/>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,819 @@
<template>
<!-- 本体部分 -->
<div
:class="['vue-puzzle-vcode', { show_: show }]"
@mousedown="onCloseMouseDown"
@mouseup="onCloseMouseUp"
@touchstart="onCloseMouseDown"
@touchend="onCloseMouseUp"
>
<div
class="vue-auth-box_ rounded-lg bg-white dark:bg-gray-800"
@mousedown.stop
@touchstart.stop
>
<div class="auth-body_" :style="`height: ${canvasHeight}px`">
<!-- 主图有缺口 -->
<canvas
ref="canvas1"
:width="canvasWidth"
:height="canvasHeight"
:style="`width:${canvasWidth}px;height:${canvasHeight}px`"
/>
<!-- 成功后显示的完整图 -->
<canvas
ref="canvas3"
:class="['auth-canvas3_', { show: isSuccess }]"
:width="canvasWidth"
:height="canvasHeight"
:style="`width:${canvasWidth}px;height:${canvasHeight}px`"
/>
<!-- 小图 -->
<canvas
:width="puzzleBaseSize"
class="auth-canvas2_"
:height="canvasHeight"
ref="canvas2"
:style="`width:${puzzleBaseSize}px;height:${canvasHeight}px;transform:translateX(${
styleWidth -
sliderBaseSize -
(puzzleBaseSize - sliderBaseSize) *
((styleWidth - sliderBaseSize) / (canvasWidth - sliderBaseSize))
}px)`"
/>
<div :class="['loading-box_', { hide_: !loading }]">
<div class="loading-gif_">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
</div>
<div :class="['info-box_', { show: infoBoxShow }, { fail: infoBoxFail }]">
{{ infoText }}
</div>
<div
:class="['flash_', { show: isSuccess }]"
:style="`transform: translateX(${
isSuccess ? `${canvasWidth + canvasHeight * 0.578}px` : `-${canvasHeight * 0.578}px`
}) skew(-30deg, 0);`"
></div>
<img class="reset_" @click="reset" :src="resetSvg" />
</div>
<div class="auth-control_">
<div class="range-box bg-gray-100 dark:bg-gray-700" :style="`height:${sliderBaseSize}px`">
<div class="range-text">{{ sliderText }}</div>
<div class="range-slider" ref="range-slider" :style="`width:${styleWidth}px`">
<div
:class="['range-btn', { isDown: mouseDown }]"
:style="`width:${sliderBaseSize}px`"
@mousedown="onRangeMouseDown($event)"
@touchstart="onRangeMouseDown($event)"
>
<div></div>
<div></div>
<div></div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import resetSvg from '@/assets/reset.png'
export default {
props: {
canvasWidth: { type: Number, default: 310 }, // 主canvas的宽
canvasHeight: { type: Number, default: 160 }, // 主canvas的高
// 是否出现,由父级控制
show: { type: Boolean, default: false },
puzzleScale: { type: Number, default: 1 }, // 拼图块的大小缩放比例
sliderSize: { type: Number, default: 40 }, // 滑块的大小
range: { type: Number, default: 10 }, // 允许的偏差值
// 所有的背景图片
imgs: {
type: Array,
},
successText: {
type: String,
default: '验证通过!',
},
failText: {
type: String,
default: '验证失败,请重试',
},
sliderText: {
type: String,
default: '拖动滑块完成拼图',
},
},
data() {
return {
mouseDown: false, // 鼠标是否在按钮上按下
startWidth: 50, // 鼠标点下去时父级的width
startX: 0, // 鼠标按下时的X
newX: 0, // 鼠标当前的偏移X
pinX: 0, // 拼图的起始X
pinY: 0, // 拼图的起始Y
loading: false, // 是否正在加在中主要是等图片onload
isCanSlide: false, // 是否可以拉动滑动条
error: false, // 图片加在失败会出现这个,提示用户手动刷新
infoBoxShow: false, // 提示信息是否出现
infoText: '', // 提示等信息
infoBoxFail: false, // 是否验证失败
timer1: null, // setTimout1
closeDown: false, // 为了解决Mac上的click BUG
isSuccess: false, // 验证成功
imgIndex: -1, // 用于自定义图片时不会随机到重复的图片
isSubmting: false, // 是否正在判定,主要用于判定中不能点击重置按钮
resetSvg,
}
},
/** 生命周期 **/
mounted() {
document.body.appendChild(this.$el)
document.addEventListener('mousemove', this.onRangeMouseMove, false)
document.addEventListener('mouseup', this.onRangeMouseUp, false)
document.addEventListener('touchmove', this.onRangeMouseMove, {
passive: false,
})
document.addEventListener('touchend', this.onRangeMouseUp, false)
if (this.show) {
document.body.classList.add('vue-puzzle-overflow')
this.reset()
}
},
beforeDestroy() {
clearTimeout(this.timer1)
document.body.removeChild(this.$el)
document.removeEventListener('mousemove', this.onRangeMouseMove, false)
document.removeEventListener('mouseup', this.onRangeMouseUp, false)
document.removeEventListener('touchmove', this.onRangeMouseMove, {
passive: false,
})
document.removeEventListener('touchend', this.onRangeMouseUp, false)
},
/** 监听 **/
watch: {
show(newV) {
// 每次出现都应该重新初始化
if (newV) {
document.body.classList.add('vue-puzzle-overflow')
this.reset()
} else {
this.isSubmting = false
this.isSuccess = false
this.infoBoxShow = false
document.body.classList.remove('vue-puzzle-overflow')
}
},
},
/** 计算属性 **/
computed: {
// styleWidth是底部用户操作的滑块的父级就是轨道在鼠标的作用下应该具有的宽度
styleWidth() {
const w = this.startWidth + this.newX - this.startX
return w < this.sliderBaseSize
? this.sliderBaseSize
: w > this.canvasWidth
? this.canvasWidth
: w
},
// 图中拼图块的60 * 用户设定的缩放比例计算之后的值 0.2~2
puzzleBaseSize() {
return Math.round(Math.max(Math.min(this.puzzleScale, 2), 0.2) * 52.5 + 6)
},
// 处理一下sliderSize弄成整数以免计算有偏差
sliderBaseSize() {
return Math.max(Math.min(Math.round(this.sliderSize), Math.round(this.canvasWidth * 0.5)), 10)
},
},
/** 方法 **/
methods: {
// 关闭
onClose() {
if (!this.mouseDown && !this.isSubmting) {
clearTimeout(this.timer1)
this.$emit('close')
}
},
onCloseMouseDown() {
this.closeDown = true
},
onCloseMouseUp() {
if (this.closeDown) {
this.onClose()
}
this.closeDown = false
},
// 鼠标按下准备拖动
onRangeMouseDown(e) {
if (this.isCanSlide) {
this.mouseDown = true
this.startWidth = this.$refs['range-slider'].clientWidth
this.newX = e.clientX || e.changedTouches[0].clientX
this.startX = e.clientX || e.changedTouches[0].clientX
}
},
// 鼠标移动
onRangeMouseMove(e) {
if (this.mouseDown) {
e.preventDefault()
this.newX = e.clientX || e.changedTouches[0].clientX
}
},
// 鼠标抬起
onRangeMouseUp() {
if (this.mouseDown) {
this.mouseDown = false
this.submit()
}
},
/**
* 开始进行
* @param withCanvas 是否强制使用canvas随机作图
*/
init(withCanvas) {
// 防止重复加载导致的渲染错误
if (this.loading && !withCanvas) {
return
}
this.loading = true
this.isCanSlide = false
const c = this.$refs.canvas1
const c2 = this.$refs.canvas2
const c3 = this.$refs.canvas3
const ctx = c.getContext('2d')
const ctx2 = c2.getContext('2d')
const ctx3 = c3.getContext('2d')
const isFirefox =
navigator.userAgent.indexOf('Firefox') >= 0 && navigator.userAgent.indexOf('Windows') >= 0 // 是windows版火狐
const img = document.createElement('img')
ctx.fillStyle = 'rgba(255,255,255,1)'
ctx3.fillStyle = 'rgba(255,255,255,1)'
ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
ctx2.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
// 取一个随机坐标,作为拼图块的位置
this.pinX = this.getRandom(this.puzzleBaseSize, this.canvasWidth - this.puzzleBaseSize - 20) // 留20的边距
this.pinY = this.getRandom(20, this.canvasHeight - this.puzzleBaseSize - 20) // 主图高度 - 拼图块自身高度 - 20边距
img.crossOrigin = 'anonymous' // 匿名,想要获取跨域的图片
img.onload = () => {
const [x, y, w, h] = this.makeImgSize(img)
ctx.save()
// 先画小图
this.paintBrick(ctx)
ctx.closePath()
if (!isFirefox) {
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
ctx.shadowColor = '#000'
ctx.shadowBlur = 3
ctx.fill()
ctx.clip()
} else {
ctx.clip()
ctx.save()
ctx.shadowOffsetX = 0
ctx.shadowOffsetY = 0
ctx.shadowColor = '#000'
ctx.shadowBlur = 3
ctx.fill()
ctx.restore()
}
ctx.drawImage(img, x, y, w, h)
ctx3.fillRect(0, 0, this.canvasWidth, this.canvasHeight)
ctx3.drawImage(img, x, y, w, h)
// 设置小图的内阴影
ctx.globalCompositeOperation = 'source-atop'
this.paintBrick(ctx)
ctx.arc(
this.pinX + Math.ceil(this.puzzleBaseSize / 2),
this.pinY + Math.ceil(this.puzzleBaseSize / 2),
this.puzzleBaseSize * 1.2,
0,
Math.PI * 2,
true
)
ctx.closePath()
ctx.shadowColor = 'rgba(255, 255, 255, .8)'
ctx.shadowOffsetX = -1
ctx.shadowOffsetY = -1
ctx.shadowBlur = Math.min(Math.ceil(8 * this.puzzleScale), 12)
ctx.fillStyle = '#ffffaa'
ctx.fill()
// 将小图赋值给ctx2
const imgData = ctx.getImageData(
this.pinX - 3, // 为了阴影 是从-3px开始截取判定的时候要+3px
this.pinY - 20,
this.pinX + this.puzzleBaseSize + 5,
this.pinY + this.puzzleBaseSize + 5
)
ctx2.putImageData(imgData, 0, this.pinY - 20)
// ctx2.drawImage(c, this.pinX - 3,this.pinY - 20,this.pinX + this.puzzleBaseSize + 5,this.pinY + this.puzzleBaseSize + 5,
// 0, this.pinY - 20, this.pinX + this.puzzleBaseSize + 5, this.pinY + this.puzzleBaseSize + 5);
// 清理
ctx.restore()
ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
// 画缺口
ctx.save()
this.paintBrick(ctx)
ctx.globalAlpha = 0.8
ctx.fillStyle = '#ffffff'
ctx.fill()
ctx.restore()
// 画缺口的内阴影
ctx.save()
ctx.globalCompositeOperation = 'source-atop'
this.paintBrick(ctx)
ctx.arc(
this.pinX + Math.ceil(this.puzzleBaseSize / 2),
this.pinY + Math.ceil(this.puzzleBaseSize / 2),
this.puzzleBaseSize * 1.2,
0,
Math.PI * 2,
true
)
ctx.shadowColor = '#000'
ctx.shadowOffsetX = 2
ctx.shadowOffsetY = 2
ctx.shadowBlur = 16
ctx.fill()
ctx.restore()
// 画整体背景图
ctx.save()
ctx.globalCompositeOperation = 'destination-over'
ctx.drawImage(img, x, y, w, h)
ctx.restore()
this.loading = false
this.isCanSlide = true
}
img.onerror = () => {
this.init(true) // 如果图片加载错误就重新来并强制用canvas随机作图
}
if (!withCanvas && this.imgs && this.imgs.length) {
let randomNum = this.getRandom(0, this.imgs.length - 1)
if (randomNum === this.imgIndex) {
if (randomNum === this.imgs.length - 1) {
randomNum = 0
} else {
randomNum++
}
}
this.imgIndex = randomNum
img.src = this.imgs[randomNum]
} else {
img.src = this.makeImgWithCanvas()
}
},
// 工具 - 范围随机数
getRandom(min, max) {
return Math.ceil(Math.random() * (max - min) + min)
},
// 工具 - 设置图片尺寸cover方式贴合canvas尺寸 w/h
makeImgSize(img) {
const imgScale = img.width / img.height
const canvasScale = this.canvasWidth / this.canvasHeight
let x = 0,
y = 0,
w = 0,
h = 0
if (imgScale > canvasScale) {
h = this.canvasHeight
w = imgScale * h
y = 0
x = (this.canvasWidth - w) / 2
} else {
w = this.canvasWidth
h = w / imgScale
x = 0
y = (this.canvasHeight - h) / 2
}
return [x, y, w, h]
},
// 绘制拼图块的路径
paintBrick(ctx) {
const moveL = Math.ceil(15 * this.puzzleScale) // 直线移动的基础距离
ctx.beginPath()
ctx.moveTo(this.pinX, this.pinY)
ctx.lineTo(this.pinX + moveL, this.pinY)
ctx.arcTo(
this.pinX + moveL,
this.pinY - moveL / 2,
this.pinX + moveL + moveL / 2,
this.pinY - moveL / 2,
moveL / 2
)
ctx.arcTo(
this.pinX + moveL + moveL,
this.pinY - moveL / 2,
this.pinX + moveL + moveL,
this.pinY,
moveL / 2
)
ctx.lineTo(this.pinX + moveL + moveL + moveL, this.pinY)
ctx.lineTo(this.pinX + moveL + moveL + moveL, this.pinY + moveL)
ctx.arcTo(
this.pinX + moveL + moveL + moveL + moveL / 2,
this.pinY + moveL,
this.pinX + moveL + moveL + moveL + moveL / 2,
this.pinY + moveL + moveL / 2,
moveL / 2
)
ctx.arcTo(
this.pinX + moveL + moveL + moveL + moveL / 2,
this.pinY + moveL + moveL,
this.pinX + moveL + moveL + moveL,
this.pinY + moveL + moveL,
moveL / 2
)
ctx.lineTo(this.pinX + moveL + moveL + moveL, this.pinY + moveL + moveL + moveL)
ctx.lineTo(this.pinX, this.pinY + moveL + moveL + moveL)
ctx.lineTo(this.pinX, this.pinY + moveL + moveL)
ctx.arcTo(
this.pinX + moveL / 2,
this.pinY + moveL + moveL,
this.pinX + moveL / 2,
this.pinY + moveL + moveL / 2,
moveL / 2
)
ctx.arcTo(this.pinX + moveL / 2, this.pinY + moveL, this.pinX, this.pinY + moveL, moveL / 2)
ctx.lineTo(this.pinX, this.pinY)
},
// 用canvas随机生成图片
makeImgWithCanvas() {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = this.canvasWidth
canvas.height = this.canvasHeight
ctx.fillStyle = `rgb(${this.getRandom(100, 255)},${this.getRandom(
100,
255
)},${this.getRandom(100, 255)})`
ctx.fillRect(0, 0, this.canvasWidth, this.canvasHeight)
// 随机画10个图形
for (let i = 0; i < 12; i++) {
ctx.fillStyle = `rgb(${this.getRandom(100, 255)},${this.getRandom(
100,
255
)},${this.getRandom(100, 255)})`
ctx.strokeStyle = `rgb(${this.getRandom(100, 255)},${this.getRandom(
100,
255
)},${this.getRandom(100, 255)})`
if (this.getRandom(0, 2) > 1) {
// 矩形
ctx.save()
ctx.rotate((this.getRandom(-90, 90) * Math.PI) / 180)
ctx.fillRect(
this.getRandom(-20, canvas.width - 20),
this.getRandom(-20, canvas.height - 20),
this.getRandom(10, canvas.width / 2 + 10),
this.getRandom(10, canvas.height / 2 + 10)
)
ctx.restore()
} else {
// 圆
ctx.beginPath()
const ran = this.getRandom(-Math.PI, Math.PI)
ctx.arc(
this.getRandom(0, canvas.width),
this.getRandom(0, canvas.height),
this.getRandom(10, canvas.height / 2 + 10),
ran,
ran + Math.PI * 1.5
)
ctx.closePath()
ctx.fill()
}
}
return canvas.toDataURL('image/png')
},
// 开始判定
submit() {
this.isSubmting = true
// 偏差 x = puzzle的起始X - (用户真滑动的距离) + (puzzle的宽度 - 滑块的宽度) * (用户真滑动的距离/canvas总宽度
// 最后+ 的是补上slider和滑块宽度不一致造成的缝隙
const x = Math.abs(
this.pinX -
(this.styleWidth - this.sliderBaseSize) +
(this.puzzleBaseSize - this.sliderBaseSize) *
((this.styleWidth - this.sliderBaseSize) / (this.canvasWidth - this.sliderBaseSize)) -
3
)
if (x < this.range) {
// 成功
this.infoText = this.successText
this.infoBoxFail = false
this.infoBoxShow = true
this.isCanSlide = false
this.isSuccess = true
// 成功后准备关闭
clearTimeout(this.timer1)
this.timer1 = setTimeout(() => {
// 成功的回调
this.isSubmting = false
this.$emit('success', x)
}, 800)
} else {
// 失败
this.infoText = this.failText
this.infoBoxFail = true
this.infoBoxShow = true
this.isCanSlide = false
// 失败的回调
this.$emit('fail', x)
// 800ms后重置
clearTimeout(this.timer1)
this.timer1 = setTimeout(() => {
this.isSubmting = false
this.reset()
}, 800)
}
},
// 重置 - 重新设置初始状态
resetState() {
this.infoBoxFail = false
this.infoBoxShow = false
this.isCanSlide = false
this.isSuccess = false
this.startWidth = this.sliderBaseSize // 鼠标点下去时父级的width
this.startX = 0 // 鼠标按下时的X
this.newX = 0 // 鼠标当前的偏移X
},
// 重置
reset() {
if (this.isSubmting) {
return
}
this.resetState()
this.init()
},
},
}
</script>
<style lang="less">
.vue-puzzle-vcode {
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
background-color: rgba(0, 0, 0, 0.3);
z-index: 999;
opacity: 0;
pointer-events: none;
transition: opacity 200ms;
&.show_ {
opacity: 1;
pointer-events: auto;
}
}
.vue-auth-box_ {
position: absolute;
top: 45%;
left: 50%;
transform: translate(-50%, -50%);
padding: 20px;
// background: #fff;
user-select: none;
// border-radius: 3px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
.auth-body_ {
position: relative;
overflow: hidden;
border-radius: 3px;
.loading-box_ {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
background-color: rgba(0, 0, 0, 0.8);
z-index: 20;
opacity: 1;
transition: opacity 200ms;
display: flex;
align-items: center;
justify-content: center;
&.hide_ {
opacity: 0;
pointer-events: none;
.loading-gif_ {
span {
animation-play-state: paused;
}
}
}
.loading-gif_ {
flex: none;
height: 5px;
line-height: 0;
@keyframes load {
0% {
opacity: 1;
transform: scale(1.3);
}
100% {
opacity: 0.2;
transform: scale(0.3);
}
}
span {
display: inline-block;
width: 5px;
height: 100%;
margin-left: 2px;
border-radius: 50%;
background-color: #888;
animation: load 1.04s ease infinite;
&:nth-child(1) {
margin-left: 0;
}
&:nth-child(2) {
animation-delay: 0.13s;
}
&:nth-child(3) {
animation-delay: 0.26s;
}
&:nth-child(4) {
animation-delay: 0.39s;
}
&:nth-child(5) {
animation-delay: 0.52s;
}
}
}
}
.info-box_ {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 24px;
line-height: 24px;
text-align: center;
overflow: hidden;
font-size: 13px;
background-color: #83ce3f;
opacity: 0;
transform: translateY(24px);
transition: all 200ms;
color: #fff;
z-index: 10;
&.show {
opacity: 0.95;
transform: translateY(0);
}
&.fail {
background-color: #ce594b;
}
}
.auth-canvas2_ {
position: absolute;
top: 0;
left: 0;
width: 60px;
height: 100%;
z-index: 2;
}
.auth-canvas3_ {
position: absolute;
top: 0;
left: 0;
opacity: 0;
transition: opacity 600ms;
z-index: 3;
&.show {
opacity: 1;
}
}
.flash_ {
position: absolute;
top: 0;
left: 0;
width: 30px;
height: 100%;
background-color: rgba(255, 255, 255, 0.1);
z-index: 3;
&.show {
transition: transform 600ms;
}
}
.reset_ {
position: absolute;
top: 2px;
right: 2px;
width: 35px;
height: auto;
z-index: 12;
cursor: pointer;
transition: transform 200ms;
transform: rotate(0deg);
&:hover {
transform: rotate(-90deg);
}
}
}
.auth-control_ {
.range-box {
position: relative;
width: 100%;
// background-color: #eef1f8;
margin-top: 20px;
border-radius: 3px;
box-shadow: 0 0 8px rgba(240, 240, 240, 0.6) inset;
.range-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 14px;
color: #b7bcd1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
width: 100%;
}
.range-slider {
position: absolute;
height: 100%;
width: 50px;
background-color: rgba(106, 160, 255, 0.8);
border-radius: 3px;
.range-btn {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
right: 0;
width: 50px;
height: 100%;
background-color: #fff;
border-radius: 3px;
box-shadow: 0 0 4px #ccc;
cursor: pointer;
& > div {
width: 0;
height: 40%;
transition: all 200ms;
&:nth-child(2) {
margin: 0 4px;
}
border: solid 1px #6aa0ff;
}
&:hover,
&.isDown {
& > div:first-child {
border: solid 4px transparent;
height: 0;
border-right-color: #6aa0ff;
}
& > div:nth-child(2) {
border-width: 3px;
height: 0;
border-radius: 3px;
margin: 0 6px;
border-right-color: #6aa0ff;
}
& > div:nth-child(3) {
border: solid 4px transparent;
height: 0;
border-left-color: #6aa0ff;
}
}
}
}
}
}
}
.vue-puzzle-overflow {
overflow: hidden !important;
}
</style>

View File

@@ -0,0 +1,159 @@
<script setup lang="ts">
import type { ResData } from '@/api/types'
import { fetchGetQRCodeAPI, fetchGetQRSceneStrAPI, fetchLoginBySceneStrAPI } from '@/api/user'
import { useBasicLayout } from '@/hooks/useBasicLayout'
import { t } from '@/locales'
import { useAuthStore, useGlobalStoreWithOut } from '@/store'
import { DIALOG_TABS } from '@/store/modules/global'
import { message } from '@/utils/message'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
const timer = ref()
const countdownTimer = ref()
const timerStartTime = ref(0)
const wxLoginUrl = ref('')
const sceneStr = ref('')
const activeCount = ref(false)
const loading = ref(true) // 控制加载状态
const ms = message()
const authStore = useAuthStore()
const { isMobile } = useBasicLayout()
const agreedToUserAgreement = ref(true) // 读取初始状态并转换为布尔类型
const useGlobalStore = useGlobalStoreWithOut()
// 点击"用户协议及隐私政策"时,自动同意
function handleClick() {
agreedToUserAgreement.value = true // 设置为同意
useGlobalStore.updateSettingsDialog(true, DIALOG_TABS.AGREEMENT)
}
const globalConfig = computed(() => authStore.globalConfig)
function loadImage(src: string) {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = reject
img.src = src
})
}
async function getSeneStr() {
const params = {}
const res: ResData = await fetchGetQRSceneStrAPI(params)
if (res.success) {
sceneStr.value = res.data
getQrCodeUrl()
}
}
async function loginBySnece() {
if (!sceneStr.value) return
const res: ResData = await fetchLoginBySceneStrAPI({
sceneStr: sceneStr.value,
})
if (res.data) {
clearInterval(timer.value)
ms.success(t('login.loginSuccess'))
authStore.setToken(res.data)
authStore.getUserInfo()
authStore.setLoginDialog(false)
}
}
async function getQrCodeUrl() {
loading.value = true // 开始加载
const res: ResData = await fetchGetQRCodeAPI({ sceneStr: sceneStr.value })
if (res.success) {
activeCount.value = true
await loadImage(res.data)
wxLoginUrl.value = res.data
loading.value = false // 加载完成
timerStartTime.value = Date.now()
timer.value = setInterval(() => {
if (Date.now() - timerStartTime.value > 60000) {
clearInterval(timer.value)
return
}
loginBySnece()
}, 1000)
}
}
function handleTimeDown() {
// clearInterval(timer.value);
getSeneStr()
// 重新获取二维码无需依赖 countdownRef
}
onMounted(() => {
handleTimeDown()
if (countdownTimer.value !== null) {
clearInterval(countdownTimer.value)
}
countdownTimer.value = setInterval(handleTimeDown, 60000)
// getSeneStr();
})
onBeforeUnmount(() => {
// 清除用于检测的timer
if (timer.value !== null) {
clearInterval(timer.value)
}
// 组件卸载时也清除handleTimeDown的countdownTimer
if (countdownTimer.value !== null) {
clearInterval(countdownTimer.value)
}
})
</script>
<template>
<div class="w-full h-full flex flex-col justify-between" :class="isMobile ? 'px-5 ' : 'px-10 '">
<div class="flex flex-col items-center flex-1">
<div class="relative w-[200px] h-[200px] mb-6 mt-auto">
<img
v-if="wxLoginUrl && (agreedToUserAgreement || globalConfig.isAutoOpenAgreement !== '1')"
class="w-full h-full select-none shadow-sm rounded-lg object-cover border border-gray-100 dark:border-gray-700"
:src="wxLoginUrl"
alt="微信登录二维码"
/>
<div
v-else
class="w-full h-full rounded-lg bg-gray-200 dark:bg-gray-700 animate-pulse"
></div>
<div
v-if="loading"
class="absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2"
>
<div
class="animate-spin rounded-full h-10 w-10 border-b-2 border-primary-600 dark:border-primary-400"
></div>
</div>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">请使用微信扫描二维码登录</p>
<div v-if="globalConfig.isAutoOpenAgreement === '1'" class="flex items-center mt-2">
<input
v-model="agreedToUserAgreement"
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600 dark:border-gray-700 dark:bg-gray-800"
/>
<p class="ml-2 text-sm text-gray-600 dark:text-gray-400">
扫码登录即代表同意
<a
href="#"
class="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
@click="handleClick"
>用户协议及隐私协议</a
>
</p>
</div>
</div>
<!-- 添加空白div保持与Email组件对齐 -->
<div class="h-6"></div>
</div>
</template>

View File

@@ -0,0 +1,89 @@
<template>
<Teleport to="body">
<TransitionGroup
enter-active-class="transition duration-200 ease-out"
enter-from-class="transform scale-95 opacity-0"
enter-to-class="scale-100 opacity-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="transform scale-95 opacity-0"
>
<div
v-for="msg in messages"
:key="msg.id"
class="fixed top-8 left-1/2 -translate-x-1/2 z-[999999] flex items-center px-4 py-2 rounded-lg shadow-sm overflow-hidden whitespace-nowrap"
:class="{
'bg-emerald-50 dark:bg-emerald-500/10': msg.type === 'success',
'bg-red-50 dark:bg-red-500/10': msg.type === 'error',
'bg-yellow-50 dark:bg-yellow-500/10': msg.type === 'warning',
'bg-blue-50 dark:bg-blue-500/10': msg.type === 'info',
'max-w-[70vw]': isMobile,
'max-w-[40vw]': !isMobile,
}"
>
<div class="flex items-center gap-2 overflow-hidden">
<CheckOne
v-if="msg.type === 'success'"
theme="filled"
size="20"
class="text-emerald-500 dark:text-emerald-400 flex-shrink-0"
/>
<CloseOne
v-if="msg.type === 'error'"
theme="filled"
size="20"
class="text-red-500 dark:text-red-400 flex-shrink-0"
/>
<Attention
v-if="msg.type === 'warning'"
theme="filled"
size="20"
class="text-yellow-500 dark:text-yellow-400 flex-shrink-0"
/>
<Info
v-if="msg.type === 'info'"
theme="filled"
size="20"
class="text-blue-500 dark:text-blue-400 flex-shrink-0"
/>
<span class="text-gray-900 dark:text-gray-100 truncate">{{ msg.content }}</span>
</div>
</div>
</TransitionGroup>
</Teleport>
</template>
<script setup lang="ts">
import { useBasicLayout } from '@/hooks/useBasicLayout'
import type { MessageOptions } from '@/utils/message'
import { Attention, CheckOne, CloseOne, Info } from '@icon-park/vue-next'
import { ref } from 'vue'
interface Message extends MessageOptions {
id: number
}
const messages = ref<Message[]>([])
let messageId = 0
const { isMobile } = useBasicLayout()
const show = (options: MessageOptions) => {
const id = messageId++
const msg = {
id,
type: options.type || 'info',
content: options.content,
}
messages.value.push(msg)
setTimeout(() => {
messages.value = messages.value.filter(m => m.id !== id)
}, options.duration || 3000)
}
defineExpose({
show,
})
</script>

View File

@@ -0,0 +1,228 @@
<template>
<transition name="modal-fade">
<div
v-if="props.visible"
class="fixed inset-0 z-[9000] flex items-center justify-center bg-gray-900 bg-opacity-50"
>
<div class="w-full h-full bg-white dark:bg-gray-750 flex flex-col overflow-hidden">
<!-- 标题部分 -->
<div
class="flex justify-between items-center mb-2 flex-shrink-0 px-4 pt-4 pb-2 border-b dark:border-gray-600"
>
<div class="flex items-center">
<button
v-if="currentView !== 'main'"
@click="backToMainView"
class="mr-2 p-1 rounded-full text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none"
>
<ArrowLeft size="20" />
</button>
<span class="text-xl font-semibold dark:text-white">
{{ currentView === 'main' ? '设置' : currentTabTitle }}
</span>
</div>
<button
@click="handleClose"
class="p-1 rounded-full text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none"
>
<Close size="20" />
</button>
</div>
<!-- 主体部分 -->
<div class="flex flex-col flex-grow overflow-y-auto px-3 pb-4">
<!-- 主菜单页面 -->
<div v-if="currentView === 'main'" class="flex-grow py-2">
<div
v-for="(tab, index) in tabs"
:key="`mobile-tab-${index}`"
class="mb-1 border-b dark:border-gray-600 last:border-b-0"
>
<div
@click="navigateToTab(index)"
class="flex justify-between items-center px-4 py-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg transition-colors duration-150"
:class="{
'text-gray-800 dark:text-gray-200': true,
}"
>
<span class="font-medium text-base">{{ tab.name }}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-gray-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
clip-rule="evenodd"
/>
</svg>
</div>
</div>
</div>
<!-- 二级页面内容 -->
<div v-else class="flex-grow py-2">
<keep-alive>
<component
v-if="tabs[currentViewIndex]?.component"
:is="tabs[currentViewIndex].component"
:key="`mobile-component-${currentViewIndex}-${activeKey}`"
:visible="props.visible && currentView !== 'main'"
></component>
</keep-alive>
</div>
<!-- 退出登录按钮 (只在主页面显示) -->
<div v-if="currentView === 'main'" class="mt-auto pt-4 pb-2 flex-shrink-0 px-4">
<button
@click="showLogoutConfirmation"
class="w-full flex items-center justify-center gap-3 px-4 py-3 rounded-lg cursor-pointer group font-medium text-sm text-red-500 dark:text-red-400 bg-red-50 dark:bg-red-900/30 hover:bg-red-100 dark:hover:bg-red-900/50 border border-red-200 dark:border-red-500/50 transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-red-300 dark:focus:ring-red-600"
>
退出登录
</button>
</div>
</div>
</div>
</div>
</transition>
</template>
<script setup lang="ts">
import { useAuthStore, useGlobalStore } from '@/store'
import { dialog } from '@/utils/dialog'
import { ArrowLeft, Close } from '@icon-park/vue-next'
import { computed, markRaw, ref, watch } from 'vue'
// Import setting components directly
import AccountManagement from './Settings/AccountManagement.vue'
import MemberCenter from './Settings/MemberCenter.vue'
import NoticeDialog from './Settings/NoticeDialog.vue'
import UserAgreement from './Settings/UserAgreement.vue'
interface Props {
visible: boolean
}
const props = defineProps<Props>()
const globalStore = useGlobalStore()
const authStore = useAuthStore()
const globalConfig = computed(() => authStore.globalConfig)
// 通过计算属性获取初始标签页
const initialTab = computed(() => globalStore.mobileInitialTab)
// 使用computed让tabs内容随条件变化
const tabs = computed(() => {
const baseTabs = [
{ name: '账户管理', component: markRaw(AccountManagement), id: 'account' },
{ name: '会员中心', component: markRaw(MemberCenter), id: 'member' },
// { name: '数据管理', component: markRaw(DataManagement), id: 'data' },
{ name: '网站公告', component: markRaw(NoticeDialog), id: 'notice' },
]
// 只有当 globalConfig.isAutoOpenAgreement === '1' 时才添加用户协议选项
if (globalConfig.value.isAutoOpenAgreement === '1') {
baseTabs.push({ name: '用户协议', component: markRaw(UserAgreement), id: 'agreement' })
}
return baseTabs
})
// 页面导航状态
const currentView = ref('main') // 'main' 或 'tab'
const currentViewIndex = ref(-1) // 当前查看的tab索引
const activeKey = ref(Date.now())
// 当前选中的tab标题
const currentTabTitle = computed(() => {
return currentViewIndex.value >= 0 ? tabs.value[currentViewIndex.value].name : '设置'
})
// 导航到特定Tab
function navigateToTab(index: number) {
if (index < 0 || index >= tabs.value.length) return
currentViewIndex.value = index
currentView.value = 'tab'
activeKey.value = Date.now() // 强制刷新组件
}
// 根据ID导航到特定Tab
function navigateToTabById(tabId: string) {
const index = tabs.value.findIndex(tab => tab.id === tabId)
if (index !== -1) {
navigateToTab(index)
}
}
// 返回主视图
function backToMainView() {
currentView.value = 'main'
currentViewIndex.value = -1
}
// Close Handler
function handleClose() {
globalStore.updateMobileSettingsDialog(false)
// 重置为主视图,以便下次打开时从主视图开始
setTimeout(() => {
backToMainView()
}, 300)
}
// 监听visible和initialTab变化
watch(
[() => props.visible, initialTab],
([isVisible, tabId]) => {
if (isVisible && tabId) {
navigateToTabById(tabId)
} else if (!isVisible) {
// 当对话框关闭时,延迟重置视图,以便关闭动画完成后不会看到视图突然变化
setTimeout(() => {
backToMainView()
}, 300)
}
},
{ immediate: true }
)
// Logout Handler
function showLogoutConfirmation() {
const dialogInstance = dialog()
dialogInstance.warning({
title: '退出登录',
content: '确定要退出登录吗?',
positiveText: '确认',
negativeText: '取消',
onPositiveClick: () => {
authStore.logOut()
handleClose() // Close settings after logout
},
})
}
</script>
<style scoped>
/* Modal fade transition */
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.5s;
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
/* 页面切换过渡 */
.page-enter-active,
.page-leave-active {
transition: transform 0.3s ease-out;
}
.page-enter-from {
transform: translateX(100%);
}
.page-leave-to {
transform: translateX(-100%);
}
</style>

View File

@@ -0,0 +1,284 @@
<script setup lang="ts">
import { fetchSendSms } from '@/api'
import { fetchVerifyPhoneIdentityAPI } from '@/api/user'
import { useBasicLayout } from '@/hooks/useBasicLayout'
import { useAuthStore, useGlobalStoreWithOut } from '@/store'
import { message } from '@/utils/message'
import { Close } from '@icon-park/vue-next'
import { computed, ref } from 'vue'
import SliderCaptcha from './Login/SliderCaptcha.vue'
defineProps<Props>()
const ms = message()
const { isMobile } = useBasicLayout()
const isShow = ref(false)
const useGlobalStore = useGlobalStoreWithOut()
const loading = ref(false)
const formRef = ref<HTMLFormElement | null>(null)
const lastSendPhoneCodeTime = ref(0)
const authStore = useAuthStore()
const globalConfig = computed(() => authStore.globalConfig)
const identityForm = ref({
username: '',
password: '',
confirmPassword: '',
phone: '',
code: '',
})
const rules = {
phone: [
{ required: true, message: '请输入手机号' },
{
pattern: /^1[3456789]\d{9}$/,
message: '手机号格式错误',
},
],
code: [{ required: true, message: '请输入验证码' }],
username: [{ required: true, message: '请输入用户名' }],
password: [{ required: true, message: '请输入密码' }],
confirmPassword: [
{ required: true, message: '请再次输入密码' },
{
validator(rule: any, value: string) {
if (value !== identityForm.value.password) {
return new Error('两次输入的密码不一致')
}
return true
},
trigger: 'blur',
},
],
}
// 使用 ref 来管理全局参数的状态
const agreedToUserAgreement = ref(true) // 读取初始状态并转换为布尔类型
// 点击"用户协议及隐私政策"时,自动同意
function handleClick() {
agreedToUserAgreement.value = true // 设置为同意
useGlobalStore.updateUserAgreementDialog(true)
}
function handlerSubmit() {
if (agreedToUserAgreement.value === false && globalConfig.value.isAutoOpenAgreement === '1') {
return ms.error(`请阅读并同意《${globalConfig.value.agreementTitle}`)
}
isShow.value = false
fetchVerifyPhoneIdentityAPI(identityForm.value).then((res: any) => {
if (res.code === 200) {
ms.success('认证成功')
useGlobalStore.updatePhoneDialog(false)
} else {
return
}
})
}
/* 发送验证码 */
async function handleSendCaptcha() {
isShow.value = false
// 手动验证表单
const { phone } = identityForm.value
if (!phone) {
ms.error('请输入手机号')
return
}
if (!/^1[3456789]\d{9}$/.test(phone)) {
ms.error('手机号格式错误')
return
}
try {
const params: any = { phone }
let res: any
res = await fetchSendSms(params)
const { success, message } = res
if (success) {
ms.success(res.data)
// 记录重新发送倒计时
lastSendPhoneCodeTime.value = 60
changeLastSendPhoneCodeTime()
} else {
return
}
} catch (error) {
console.error('发送验证码失败:', error)
}
}
// 定时器改变倒计时时间方法
function changeLastSendPhoneCodeTime() {
if (lastSendPhoneCodeTime.value > 0) {
setTimeout(() => {
lastSendPhoneCodeTime.value--
changeLastSendPhoneCodeTime()
}, 1000)
}
}
interface Props {
visible: boolean
}
</script>
<template>
<div
v-if="visible"
class="fixed inset-0 z-50 flex flex-col items-center justify-center bg-black bg-opacity-50 py-6"
>
<div
class="bg-white p-6 rounded-lg shadow-lg w-full max-h-[70vh] flex flex-col dark:bg-gray-900 dark:text-gray-400 relative"
:class="{ 'max-w-[95vw]': isMobile, 'max-w-xl': !isMobile }"
>
<Close
size="18"
class="absolute top-3 right-3 cursor-pointer z-30"
@click="useGlobalStore.updatePhoneDialog(false)"
/>
<div class="flex-1 flex flex-col items-center">
<div
class="flex w-full flex-col h-full justify-center"
:class="isMobile ? 'px-5 py-5' : 'px-10 py-5'"
>
<form
ref="formRef"
:model="identityForm"
:rules="rules"
label-placement="left"
label-width="auto"
require-mark-placement="right-hanging"
>
<div class="sm:mx-auto sm:w-full sm:max-w-sm">
<h2
class="mb-8 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900 dark:text-gray-300"
>
手机号绑定
</h2>
</div>
<div class="mt-4 flex">
<input
id="userPhone"
type="text"
v-model="identityForm.phone"
placeholder="请输入手机号"
class="flex-1 block w-full rounded-md border-0 py-2 px-3 text-gray-900 shadow-sm dark:text-gray-300 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-gray-300 sm:text-sm sm:leading-6 dark:bg-gray-800 dark:focus:ring-gray-400"
/>
</div>
<div class="mt-4 relative">
<input
id="username"
type="text"
v-model="identityForm.code"
placeholder="请输入验证码"
class="block w-full rounded-md border-0 py-2 px-2 text-gray-900 shadow-sm dark:text-gray-300 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-gray-300 sm:text-sm sm:leading-6 dark:bg-gray-800 dark:focus:ring-gray-400 pl-3 pr-12"
/>
<button
block
class="absolute right-0 top-1/2 transform -translate-y-1/2 flex justify-center rounded-r-md bg-primary-500 px-3 py-2 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-primary-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
:disabled="loading"
:loading="loading"
@click="isShow = true"
>
发送验证码
</button>
</div>
<div class="mt-4">
<div class="mt-2">
<input
id="username"
type="text"
v-model="identityForm.username"
placeholder="请输入用户名"
class="block w-full rounded-md border-0 py-2 px-3 text-gray-900 shadow-sm dark:text-gray-300 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-gray-300 sm:text-sm sm:leading-6 dark:bg-gray-800 dark:focus:ring-gray-400"
/>
</div>
</div>
<div class="mt-4">
<div class="mt-2">
<input
id="password"
type="password"
v-model="identityForm.password"
placeholder="请输入密码"
class="block w-full rounded-md border-0 py-2 px-3 text-gray-900 shadow-sm dark:text-gray-300 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-gray-300 sm:text-sm sm:leading-6 dark:bg-gray-800 dark:focus:ring-gray-400"
/>
</div>
</div>
<div class="mt-4">
<div class="mt-2">
<input
id="confirmPassword"
type="password"
v-model="identityForm.confirmPassword"
placeholder="请再次输入密码"
class="block w-full rounded-md border-0 py-2 px-3 text-gray-900 shadow-sm dark:text-gray-300 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-gray-300 sm:text-sm sm:leading-6 dark:bg-gray-800 dark:focus:ring-gray-400"
/>
</div>
</div>
<div
v-if="globalConfig.isAutoOpenAgreement === '1'"
class="flex items-center justify-between my-3"
>
<div class="flex items-center">
<input
v-model="agreedToUserAgreement"
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
/>
<p class="ml-1 text-center text-sm text-gray-500 dark:text-gray-400">
已阅读并同意
<a
href="#"
class="font-semibold leading-6 text-primary-600 hover:text-primary-500 dark:text-primary-500 dark:hover:text-primary-600"
@click="handleClick"
>{{ globalConfig.agreementTitle }}</a
>
</p>
</div>
</div>
<!-- <div class="mt-4">
<div class="mt-2">
<input
id="phone"
type="text"
v-model="identityForm.phone"
placeholder="请输入手机号"
class="block w-full rounded-md border-0 py-2 px-3 text-gray-900 shadow-sm dark:text-gray-300 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-gray-300 sm:text-sm sm:leading-6 dark:bg-gray-800 dark:focus:ring-gray-400"
/>
</div>
</div> -->
<div>
<button
@click="handlerSubmit()"
type="submit"
class="flex w-full my-5 justify-center rounded-md bg-primary-500 px-3 py-2 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-primary-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
提交认证
</button>
</div>
<SliderCaptcha
:show="isShow"
@success="handleSendCaptcha()"
@close="isShow = false"
class="bg-red-500"
/>
</form>
</div>
</div>
</div>
</div>
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,97 @@
<script setup lang="ts">
import { useBasicLayout } from '@/hooks/useBasicLayout'
import { t } from '@/locales'
import { useChatStore } from '@/store'
import { dialog } from '@/utils/dialog'
import { Delete } from '@icon-park/vue-next'
import { ref } from 'vue'
interface Props {
visible: boolean
}
defineProps<Props>()
const chatStore = useChatStore()
const loading = ref(false)
const { isMobile } = useBasicLayout()
/* 删除全部非置顶聊天 */
async function handleClearConversations() {
const dialogInstance = dialog()
dialogInstance.warning({
title: t('chat.clearConversation'),
content: t('chat.clearAllNonFavoriteConversations'),
positiveText: t('common.confirm'),
negativeText: t('common.cancel'),
onPositiveClick: async () => {
loading.value = true
try {
await chatStore.delAllGroup()
loading.value = false
} catch (error) {
loading.value = false
}
},
})
}
</script>
<template>
<div class="overflow-y-auto custom-scrollbar p-1" :class="{ 'max-h-[70vh]': !isMobile }">
<!-- 清空对话卡片 -->
<div
class="p-4 bg-white dark:bg-gray-700 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 mb-4 flex flex-col space-y-4"
>
<!-- 卡片标题 -->
<div
class="text-base font-semibold text-gray-900 dark:text-gray-100 mb-2 pb-2 border-b border-gray-200 dark:border-gray-700"
>
清空对话记录
</div>
<div class="text-sm text-gray-600 dark:text-gray-400 mb-4">
清空所有非收藏的对话记录此操作不可撤销请谨慎操作
</div>
<button
@click="handleClearConversations"
class="flex items-center px-4 py-2 rounded-lg bg-red-500 hover:bg-red-600 text-white font-medium text-sm"
:disabled="loading"
>
<Delete theme="outline" size="16" class="mr-2" />
<span>清空记录</span>
</button>
</div>
</div>
</template>
<style scoped>
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(155, 155, 155, 0.5) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(155, 155, 155, 0.5);
border-radius: 20px;
border: transparent;
}
/* 暗黑模式下滚动条样式 */
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(100, 100, 100, 0.5);
}
.dark .custom-scrollbar {
scrollbar-color: rgba(100, 100, 100, 0.5) transparent;
}
</style>

View File

@@ -0,0 +1,782 @@
<script setup lang="ts">
import { fetchGetPackageAPI, fetchUseCramiAPI } from '@/api/crami'
import { fetchOrderBuyAPI } from '@/api/order'
import { fetchSignInAPI, fetchSignLogAPI } from '@/api/signin'
import { fetchGetJsapiTicketAPI } from '@/api/user'
import { useBasicLayout } from '@/hooks/useBasicLayout'
import { t } from '@/locales'
import { message } from '@/utils/message'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import type { ResData } from '@/api/types'
import { useAuthStore, useGlobalStoreWithOut } from '@/store'
import MemberPayment from './MemberPayment.vue'
const props = defineProps<Props>()
declare let WeixinJSBridge: any
declare let wx: any
const authStore = useAuthStore()
const useGlobalStore = useGlobalStoreWithOut()
const loading = ref(true)
const packageList = ref<Pkg[]>([])
const ms = message()
const dialogLoading = ref(false)
const model3Name = computed(() => authStore.globalConfig.model3Name || t('goods.basicModelQuota'))
const { isMobile } = useBasicLayout()
const model4Name = computed(
() => authStore.globalConfig.model4Name || t('goods.advancedModelQuota')
)
const drawMjName = computed(() => authStore.globalConfig.drawMjName || t('goods.drawingQuota'))
const isHideModel3Point = computed(() => Number(authStore.globalConfig.isHideModel3Point) === 1)
const isHideModel4Point = computed(() => Number(authStore.globalConfig.isHideModel4Point) === 1)
const isHideDrawMjPoint = computed(() => Number(authStore.globalConfig.isHideDrawMjPoint) === 1)
const isWxEnv = computed(() => {
const ua = window.navigator.userAgent.toLowerCase()
return ua.match(/MicroMessenger/i) && ua?.match(/MicroMessenger/i)?.[0] === 'micromessenger'
})
const payPlatform = computed(() => {
const {
payHupiStatus,
payEpayStatus,
payMpayStatus,
payWechatStatus,
payLtzfStatus,
payDuluPayStatus,
} = authStore.globalConfig
if (Number(payWechatStatus) === 1) return 'wechat'
if (Number(payMpayStatus) === 1) return 'mpay'
if (Number(payHupiStatus) === 1) return 'hupi'
if (Number(payEpayStatus) === 1) return 'epay'
if (Number(payLtzfStatus) === 1) return 'ltzf'
if (Number(payDuluPayStatus) === 1) return 'dulu'
return null
})
const payChannel = computed(() => {
const { payEpayChannel, payMpayChannel } = authStore.globalConfig
if (payPlatform.value === 'mpay') return payMpayChannel ? JSON.parse(payMpayChannel) : []
if (payPlatform.value === 'epay') return payEpayChannel ? JSON.parse(payEpayChannel) : []
if (payPlatform.value === 'wechat') return ['wxpay']
if (payPlatform.value === 'hupi') return ['hupi']
if (payPlatform.value === 'dulu') return ['dulu']
if (payPlatform.value === 'ltzf') return ['wxpay']
return []
})
interface Props {
visible: boolean
}
interface Pkg {
id: number
name: string
coverImg: string
des: string
price: number
model3Count: number
model4Count: number
drawMjCount: number
extraReward: number
extraPaintCount: number
createdAt: Date
}
onMounted(() => {
if (props.visible) {
// 组件挂载时检查登录状态
if (checkLoginStatus()) {
openDrawerAfter()
if (isWxEnv.value) jsapiInitConfig()
}
}
})
// 二级页面控制
const activeView = ref('main') // 'main'或'payment'
const selectedPackage = ref<Pkg | null>(null)
// 切换到支付页面
function showPaymentView(pkg: Pkg) {
selectedPackage.value = pkg
useGlobalStore.updateOrderInfo({ pkgInfo: pkg })
activeView.value = 'payment'
}
// 返回主视图
function backToMainView() {
activeView.value = 'main'
selectedPackage.value = null
}
// 处理支付成功
function handlePaymentSuccess() {
ms.success(t('goods.purchaseSuccess'))
activeView.value = 'main'
selectedPackage.value = null
// 刷新用户信息
authStore.getUserInfo()
// 关闭设置对话框
setTimeout(() => {
useGlobalStore.updateSettingsDialog(false)
}, 2000)
}
onBeforeUnmount(() => {
packageList.value = []
loading.value = true
// 确保返回主视图,清理资源
activeView.value = 'main'
selectedPackage.value = null
})
/* 微信环境jsapi注册 */
async function jsapiInitConfig() {
const url = window.location.href.replace(/#.*$/, '')
const res = (await fetchGetJsapiTicketAPI({ url })) as ResData
const { appId, nonceStr, timestamp, signature } = res.data
if (!appId) return
wx.config({
debug: false,
appId,
timestamp,
nonceStr,
signature,
jsApiList: ['chooseWXPay'],
})
wx.ready(() => {})
wx.error(() => {})
}
function onBridgeReady(data: {
appId: string
timeStamp: string
nonceStr: string
package: string
signType: string
paySign: string
}) {
const { appId, timeStamp, nonceStr, package: pkg, signType, paySign } = data
WeixinJSBridge.invoke(
'getBrandWCPayRequest',
{
appId,
timeStamp,
nonceStr,
package: pkg,
signType,
paySign,
},
(res: any) => {
if (res.err_msg === 'get_brand_wxpay_request:ok') {
ms.success(t('goods.purchaseSuccess'))
setTimeout(() => {
authStore.getUserInfo()
}, 500)
} else {
ms.success(t('goods.paymentNotSuccessful'))
}
}
)
}
async function handleBuyGoods(pkg: Pkg) {
if (dialogLoading.value) return
// 判断是否是微信移动端环境
function isWxMobileEnv() {
const ua = window.navigator.userAgent.toLowerCase()
// 微信环境
const isWxEnv = ua.indexOf('micromessenger') !== -1
// 非PC端
const isMobile = ua.indexOf('windows') === -1 && ua.indexOf('macintosh') === -1
return isWxEnv && isMobile
}
// 如果是微信环境判断有没有开启微信支付开启了则直接调用jsapi支付即可
if (
isWxMobileEnv() &&
payPlatform.value === 'wechat' &&
Number(authStore.globalConfig.payWechatStatus) === 1
) {
if (typeof WeixinJSBridge == 'undefined') {
// 使用事件监听器而不是直接传递回调函数
const bridgeReadyHandler = () => {
// 在回调中使用onBridgeReady函数处理支付
const handlePayment = async () => {
const res: ResData = await fetchOrderBuyAPI({
goodsId: pkg.id,
payType: 'jsapi',
})
const { success, data } = res
if (success) onBridgeReady(data)
}
handlePayment()
}
if (document.addEventListener) {
document.addEventListener('WeixinJSBridgeReady', bridgeReadyHandler as EventListener, false)
}
} else {
const res: ResData = await fetchOrderBuyAPI({
goodsId: pkg.id,
payType: 'jsapi',
})
const { success, data } = res
success && onBridgeReady(data)
}
return
}
/* 其他场景打开支付窗口 */
useGlobalStore.updateOrderInfo({ pkgInfo: pkg })
}
async function openDrawerAfter() {
// 首先检查登录状态
if (!checkLoginStatus()) {
return
}
loading.value = true
try {
// 清空当前套餐列表,避免显示旧数据
packageList.value = []
// 获取用户最新余额信息
await authStore.getUserInfo()
// 获取套餐列表
const res: ResData = await fetchGetPackageAPI({ status: 1, size: 30 })
packageList.value = res.data.rows
// 获取签到记录
await getSigninLog()
loading.value = false
} catch (error) {
loading.value = false
console.error('加载会员中心数据失败:', error)
}
}
const selectName = ref('')
const handleSelect = (item: { name: string }) => {
selectName.value = item.name
cramiSelect.value = false
}
function handleSuccess(pkg: Pkg) {
// 检查支付渠道是否启用
if (!payChannel.value.length) {
ms.warning(t('goods.paymentNotEnabled'))
return
}
// 微信移动端环境需要特殊处理
if (
isWxEnv.value &&
payPlatform.value === 'wechat' &&
Number(authStore.globalConfig.payWechatStatus) === 1
) {
// 直接处理JSAPI支付
handleBuyGoods(pkg)
return
}
// 其他情况切换到支付视图
showPaymentView(pkg)
}
function splitDescription(description: string) {
return description.split('\n')
}
const code = ref('')
const cramiSelect = ref(false)
async function useCrami() {
if (!code.value.trim()) {
ms.info(t('usercenter.pleaseEnterCardDetails'))
return
}
try {
loading.value = true
await fetchUseCramiAPI({ code: code.value })
ms.success(t('usercenter.cardRedeemSuccess'))
authStore.getUserInfo()
loading.value = false
// 清空卡密输入框
code.value = ''
} catch (error: any) {
loading.value = false
// 清空卡密输入框
code.value = ''
}
}
// 由于globalConfig可能没有showCrami属性这里默认为true显示卡密兑换
const showCrami = ref(true)
const router = useRouter()
const showGoodsDialog = ref(false)
const openGoodsDialog = () => {
showGoodsDialog.value = true
}
// 签到相关状态和方法
const signInData = ref<{ signInDate: string; isSigned: boolean }[]>([])
const signInLoading = ref(false)
const today = new Date().toISOString().split('T')[0]
const days = computed(() => {
return signInData.value.map(item => ({
...item,
day: item.signInDate.split('-').pop()?.replace(/^0/, ''),
isToday: item.signInDate === today,
}))
})
const consecutiveDays = computed(() => authStore.userInfo.consecutiveDays || 0)
const signInModel3Count = computed(() => Number(authStore.globalConfig?.signInModel3Count) || 0)
const signInModel4Count = computed(() => Number(authStore.globalConfig?.signInModel4Count) || 0)
const signInMjDrawToken = computed(() => Number(authStore.globalConfig?.signInMjDrawToken) || 0)
const hasSignedInToday = computed(() => {
return signInData.value.some(item => item.signInDate === today && item.isSigned)
})
async function getSigninLog() {
try {
const res: ResData = await fetchSignLogAPI()
if (res.success) {
signInData.value = res.data || []
}
} catch (error) {
console.error('加载签到数据失败:', error)
}
}
async function handleSignIn() {
try {
signInLoading.value = true
const res: ResData = await fetchSignInAPI()
if (res.success) {
ms.success('签到成功!')
await getSigninLog()
authStore.getUserInfo()
}
signInLoading.value = false
} catch (error) {
signInLoading.value = false
console.error('签到失败:', error)
}
}
function getFirstDayOfMonth(year: number, month: number) {
return new Date(year, month, 1).getDay()
}
// 获取用户信息和余额
const userBalance = computed(() => authStore.userBalance)
const isMember = computed(() => userBalance.value.isMember || false)
// 登录状态检测
const isLogin = computed(() => authStore.isLogin)
// 登录检测函数
function checkLoginStatus() {
console.log('会员中心 - 检查登录状态:', isLogin.value)
if (!isLogin.value) {
console.log('用户未登录,关闭设置弹窗并打开登录弹窗')
// 显示消息提醒
ms.warning('请先登录后使用会员中心')
// 关闭设置弹窗
useGlobalStore.updateSettingsDialog(false)
// 打开登录弹窗
authStore.setLoginDialog(true)
return false
}
return true
}
// 监听登录状态变化
watch(isLogin, newLoginStatus => {
console.log('会员中心 - 登录状态变化:', newLoginStatus)
// 如果组件可见但用户登出了,立即关闭设置弹窗并打开登录弹窗
if (props.visible && !newLoginStatus) {
console.log('用户已登出,关闭设置弹窗并打开登录弹窗')
// 显示消息提醒
ms.warning('账户已登出,请重新登录后查看')
useGlobalStore.updateSettingsDialog(false)
authStore.setLoginDialog(true)
}
})
// 添加对visible属性的监听确保组件可见时重新加载数据
watch(
() => props.visible,
isVisible => {
if (isVisible) {
// 组件显示时立即检查登录状态
if (checkLoginStatus()) {
openDrawerAfter()
if (isWxEnv.value) jsapiInitConfig()
}
}
}
)
</script>
<template>
<div class="overflow-y-auto custom-scrollbar p-1" :class="{ 'max-h-[70vh]': !isMobile }">
<!-- 主视图 -->
<div v-if="activeView === 'main'">
<!-- 套餐列表卡片 -->
<div
class="p-4 bg-white dark:bg-gray-700 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 mb-4 flex flex-col space-y-4"
>
<!-- 卡片标题 -->
<div
class="text-base font-semibold text-gray-900 dark:text-gray-100 mb-2 pb-2 border-b border-gray-200 dark:border-gray-700"
>
套餐列表
</div>
<!-- 套餐列表 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
v-for="(item, index) in packageList"
:key="index"
:class="[
item.name == selectName
? 'ring-2 ring-primary-500 shadow-md'
: 'ring-1 ring-gray-200 dark:ring-gray-700',
'rounded-lg p-6 hover:shadow-md bg-white dark:bg-gray-750',
]"
@click="handleSelect(item)"
>
<div class="relative">
<b class="text-lg font-semibold leading-8 dark:text-white">{{ item.name }}</b>
</div>
<div v-if="!isHideModel3Point" class="flex justify-between items-end mt-4">
<span class="text-sm font-medium text-gray-500 dark:text-gray-400">{{
model3Name
}}</span>
<span class="font-bold dark:text-white">
{{ item.model3Count > 99999 ? '无限额度' : item.model3Count }}
</span>
</div>
<div v-if="!isHideModel4Point" class="flex justify-between items-end mt-2">
<span class="text-sm font-medium text-gray-500 dark:text-gray-400">{{
model4Name
}}</span>
<span class="font-bold dark:text-white">
{{ item.model4Count > 99999 ? '无限额度' : item.model4Count }}
</span>
</div>
<div v-if="!isHideDrawMjPoint" class="flex justify-between items-end mt-2">
<span class="text-sm font-medium text-gray-500 dark:text-gray-400">{{
drawMjName
}}</span>
<span class="font-bold dark:text-white">
{{ item.drawMjCount > 99999 ? '无限额度' : item.drawMjCount }}
</span>
</div>
<div class="mt-4 flex items-baseline gap-x-1">
<span class="text-3xl font-bold tracking-tight text-gray-900 dark:text-white">{{
`${item.price}`
}}</span>
</div>
<div class="mt-6">
<button @click.stop="handleSuccess(item)" class="btn btn-primary btn-md w-full">
购买套餐
</button>
</div>
<ul
v-if="item.des"
class="mt-4 space-y-2 text-sm leading-6 text-gray-600 dark:text-gray-400"
>
<li
v-for="(line, index) in splitDescription(item.des)"
:key="index"
class="flex gap-x-2"
>
<svg
class="h-5 w-5 flex-none text-primary-600 dark:text-primary-400"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clip-rule="evenodd"
/>
</svg>
{{ line }}
</li>
</ul>
</div>
</div>
</div>
<!-- 签到和余额并排显示区域 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<!-- 签到日历卡片 - 左侧 -->
<div
class="p-4 bg-white dark:bg-gray-700 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 flex flex-col space-y-4 h-full"
>
<!-- 卡片标题 -->
<div
class="text-base font-semibold text-gray-900 dark:text-gray-100 mb-2 pb-2 border-b border-gray-200 dark:border-gray-700"
>
签到奖励
</div>
<!-- 签到信息 -->
<div
class="bg-gray-50 mb-4 p-3 rounded-lg border border-gray-200 dark:border-gray-700 dark:bg-gray-700"
>
<span class="dark:text-gray-300">签到赠送</span>
<span v-if="signInModel3Count > 0 && !isHideModel3Point"
><b class="mx-2 text-primary-500">{{ signInModel3Count }}</b
><span class="dark:text-gray-300">{{ model3Name }}</span></span
>
<span v-if="signInModel4Count > 0 && !isHideModel4Point"
><b class="mx-2 text-primary-500">{{ signInModel4Count }}</b
><span class="dark:text-gray-300">{{ model4Name }}</span></span
>
<span v-if="signInMjDrawToken > 0 && !isHideDrawMjPoint"
><b class="mx-2 text-primary-500">{{ signInMjDrawToken }}</b
><span class="dark:text-gray-300">{{ drawMjName }}</span></span
>
<span class="dark:text-gray-300"
>已连续签到<b class="text-red-500 mx-1">{{ consecutiveDays }}</b
></span
>
</div>
<!-- 签到日历 -->
<div class="flex-grow">
<div
class="grid grid-cols-7 text-center text-xs leading-6 text-gray-500 dark:text-gray-400"
>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
</div>
<div class="mt-2 grid grid-cols-7 text-sm">
<div
v-for="n in getFirstDayOfMonth(new Date().getFullYear(), new Date().getMonth())"
:key="'empty-' + n"
class="py-2"
></div>
<div v-for="day in days" :key="day.signInDate" class="py-2">
<button
type="button"
:class="[
day.isToday
? 'bg-primary-600 text-white'
: day.isSigned
? 'text-primary-600 dark:text-primary-400'
: 'text-gray-900 dark:text-gray-100',
'hover:bg-gray-200 dark:hover:bg-gray-700 mx-auto flex h-8 w-8 items-center justify-center rounded-full',
]"
>
<time :datetime="day.signInDate">{{ day.day }}</time>
</button>
</div>
</div>
</div>
<!-- 签到按钮 -->
<div class="mt-4 pt-2 border-t border-gray-200 dark:border-gray-700">
<button
@click="handleSignIn"
:disabled="hasSignedInToday || signInLoading"
class="btn btn-primary btn-md w-full"
>
<span v-if="signInLoading">签到中...</span>
<span v-else-if="hasSignedInToday">已签到</span>
<span v-else>签到</span>
</button>
</div>
</div>
<!-- 钱包余额卡片 - 右侧 -->
<div
class="p-4 bg-white dark:bg-gray-700 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 flex flex-col space-y-4 h-full"
>
<!-- 卡片标题 -->
<div
class="text-base font-semibold text-gray-900 dark:text-gray-100 mb-2 pb-2 border-b border-gray-200 dark:border-gray-700"
>
额度信息
</div>
<!-- 余额信息 -->
<div class="space-y-3">
<!-- 基础模型积分 -->
<div
v-if="!isHideModel3Point"
class="flex items-center p-2 border border-gray-200 dark:border-gray-700 rounded-lg"
>
<div class="text-gray-500 dark:text-gray-400 w-28">{{ model3Name }}</div>
<div class="text-lg font-bold text-gray-900 dark:text-gray-100">
{{
userBalance.sumModel3Count > 999999
? '无限额度'
: (userBalance.sumModel3Count ?? 0)
}}
<span
v-if="userBalance.sumModel3Count <= 999999"
class="text-sm text-gray-500 dark:text-gray-400 ml-1"
>{{ t('usercenter.points') }}</span
>
</div>
</div>
<!-- 高级模型积分 -->
<div
v-if="!isHideModel4Point"
class="flex items-center p-2 border border-gray-200 dark:border-gray-700 rounded-lg"
>
<div class="text-gray-500 dark:text-gray-400 w-28">{{ model4Name }}</div>
<div class="text-lg font-bold text-gray-900 dark:text-gray-100">
{{
userBalance.sumModel4Count > 99999
? '无限额度'
: (userBalance.sumModel4Count ?? 0)
}}
<span
v-if="userBalance.sumModel4Count <= 99999"
class="text-sm text-gray-500 dark:text-gray-400 ml-1"
>{{ t('usercenter.points') }}</span
>
</div>
</div>
<!-- 绘画积分 -->
<div
v-if="!isHideDrawMjPoint"
class="flex items-center p-2 border border-gray-200 dark:border-gray-700 rounded-lg"
>
<div class="text-gray-500 dark:text-gray-400 w-28">{{ drawMjName }}</div>
<div class="text-lg font-bold text-gray-900 dark:text-gray-100">
{{
userBalance.sumDrawMjCount > 99999
? '无限额度'
: (userBalance.sumDrawMjCount ?? 0)
}}
<span
v-if="userBalance.sumDrawMjCount <= 99999"
class="text-sm text-gray-500 dark:text-gray-400 ml-1"
>{{ t('usercenter.points') }}</span
>
</div>
</div>
<!-- 会员到期时间 -->
<div
class="flex items-center p-2 border border-gray-200 dark:border-gray-700 rounded-lg"
>
<div class="text-gray-500 dark:text-gray-400 w-28">会员状态</div>
<div
class="text-lg font-bold"
:class="isMember ? 'text-red-500' : 'text-gray-500 dark:text-gray-400'"
>
{{ userBalance.expirationTime ? `${userBalance.expirationTime} 到期` : '非会员' }}
</div>
</div>
</div>
<!-- 卡密兑换部分移至此处 -->
<div
v-if="showCrami"
class="flex-grow mt-4 pt-4 border-t border-gray-200 dark:border-gray-700"
>
<div class="text-base font-medium text-gray-900 dark:text-gray-100 mb-3">卡密兑换</div>
<div class="flex items-center space-x-2">
<input
v-model="code"
:placeholder="t('usercenter.enterCardDetails')"
class="input input-md w-full"
type="text"
/>
<button
:disabled="loading || !code"
@click="useCrami"
class="btn btn-primary btn-md w-24"
>
兑换
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 支付视图 -->
<MemberPayment
v-else-if="activeView === 'payment'"
:visible="activeView === 'payment'"
@back-to-main="backToMainView"
@payment-success="handlePaymentSuccess"
/>
</div>
</template>
<style scoped>
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(155, 155, 155, 0.5) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(155, 155, 155, 0.5);
border-radius: 20px;
border: transparent;
}
/* 暗黑模式下滚动条样式 */
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(100, 100, 100, 0.5);
}
.dark .custom-scrollbar {
scrollbar-color: rgba(100, 100, 100, 0.5) transparent;
}
</style>

View File

@@ -0,0 +1,482 @@
<script setup lang="ts">
import { fetchOrderBuyAPI, fetchOrderQueryAPI } from '@/api/order'
import { useBasicLayout } from '@/hooks/useBasicLayout'
import { t } from '@/locales'
import { useAuthStore, useGlobalStore } from '@/store'
import { message } from '@/utils/message'
import { ArrowLeft } from '@icon-park/vue-next'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { ResData } from '@/api/types'
import alipay from '@/assets/alipay.png'
import wxpay from '@/assets/wxpay.png'
import QRCode from '@/components/common/QRCode/index.vue'
interface Props {
visible: boolean
}
const props = defineProps<Props>()
const emit = defineEmits(['back-to-main', 'payment-success'])
const { isMobile } = useBasicLayout()
const authStore = useAuthStore()
const useGlobal = useGlobalStore()
const POLL_INTERVAL = 1000
const ms = message()
const active = ref(true)
const payType = ref('wxpay')
/* 是否是微信环境 */
/* 是否是微信移动端环境 */
const isWxEnv = computed(() => {
const ua = window.navigator.userAgent.toLowerCase()
// 判断是否为微信环境
const isWxBrowser =
ua.match(/MicroMessenger/i) && ua?.match(/MicroMessenger/i)?.[0] === 'micromessenger'
// 判断是否为非PC端即移动端
const isMobile = !ua.includes('windows') && !ua.includes('macintosh')
// 返回是否是微信的移动端环境
return isWxBrowser && isMobile
})
/* 开启的支付平台 */
const payPlatform = computed(() => {
const {
payHupiStatus,
payEpayStatus,
payMpayStatus,
payWechatStatus,
payLtzfStatus,
payDuluPayStatus,
} = authStore.globalConfig
if (Number(payWechatStatus) === 1) return 'wechat'
if (Number(payEpayStatus) === 1) return 'epay'
if (Number(payMpayStatus) === 1) return 'mpay'
if (Number(payHupiStatus) === 1) return 'hupi'
if (Number(payLtzfStatus) === 1) return 'ltzf'
if (Number(payDuluPayStatus) === 1) return 'dulu'
return null
})
/* 支付平台开启的支付渠道 */
const payChannel = computed(() => {
const { payEpayChannel, payMpayChannel, payDuluPayChannel } = authStore.globalConfig
if (payPlatform.value === 'mpay') return payMpayChannel ? JSON.parse(payMpayChannel) : []
if (payPlatform.value === 'epay') return payEpayChannel ? JSON.parse(payEpayChannel) : []
if (payPlatform.value === 'wechat') return ['wxpay']
if (payPlatform.value === 'hupi') return ['wxpay']
if (payPlatform.value === 'ltzf') return ['wxpay']
if (payPlatform.value === 'dulu') return payDuluPayChannel ? JSON.parse(payDuluPayChannel) : []
return []
})
const plat = computed(() => {
return payType.value === 'wxpay' ? t('pay.wechat') : t('pay.alipay')
})
const countdownRef = ref<ReturnType<typeof setInterval> | null>(null)
const remainingTime = ref(60)
const isCountingDown = ref(false)
const isRedirectPay = computed(() => {
const { payEpayApiPayUrl, payDuluPayRedirect } = authStore.globalConfig
return (
(payPlatform.value === 'epay' && payEpayApiPayUrl.includes('submit')) ||
payPlatform.value === 'mpay' ||
(payPlatform.value === 'dulu' && payDuluPayRedirect === '1')
)
})
// 倒计时函数
function startCountdown() {
remainingTime.value = 300 // 5分钟倒计时
if (!countdownRef.value) {
countdownRef.value = setInterval(() => {
remainingTime.value--
if (remainingTime.value <= 0) {
handleFinish()
}
}, 1000)
}
}
// 倒计时结束处理
function handleFinish() {
if (countdownRef.value) {
clearInterval(countdownRef.value)
countdownRef.value = null
}
active.value = false
ms.warning(t('pay.paymentTimeExpired'))
backToMainView()
}
watch(payType, () => {
getQrCode()
// 重新开始倒计时
if (countdownRef.value) {
clearInterval(countdownRef.value)
countdownRef.value = null
}
startCountdown()
})
const orderId = ref('')
let timer: any
const payTypes = computed(() => {
return [
{
label: t('pay.wechatPay'),
value: 'wxpay',
icon: wxpay,
payChannel: 'wxpay',
},
{
label: t('pay.alipayPay'),
value: 'alipay',
icon: alipay,
payChannel: 'alipay',
},
].filter(item => payChannel.value.includes(item.payChannel))
})
const queryOrderStatus = async () => {
if (!orderId.value) return
const result: ResData = await fetchOrderQueryAPI({ orderId: orderId.value })
const { success, data } = result
if (success) {
const { status } = data
if (status === 1) {
stopPolling()
ms.success(t('pay.paymentSuccess'))
active.value = false
authStore.getUserInfo()
// 支付成功后通知父组件
emit('payment-success')
}
}
}
const orderInfo = computed(() => useGlobal?.orderInfo)
const url_qrcode = ref('')
const qrCodeloading = ref(true)
const redirectloading = ref(true)
const redirectUrl = ref('')
// 返回主视图
function backToMainView() {
cleanupResources()
emit('back-to-main')
}
/* 请求二维码 */
async function getQrCode() {
!isRedirectPay.value && (qrCodeloading.value = true)
isRedirectPay.value && (redirectloading.value = true)
let qsPayType = null
qsPayType = payType.value
if (payPlatform.value === 'wechat') qsPayType = isWxEnv.value ? 'jsapi' : 'native'
try {
const res: ResData = await fetchOrderBuyAPI({
goodsId: orderInfo.value.pkgInfo.id,
payType: qsPayType,
})
const { data, success } = res
if (!success) {
return
}
const { url_qrcode: code, orderId: id, redirectUrl: url } = data
redirectUrl.value = url
orderId.value = id
url_qrcode.value = code
qrCodeloading.value = false
redirectloading.value = false
} catch (error) {
backToMainView()
qrCodeloading.value = false
redirectloading.value = false
}
}
/* 跳转支付 */
function handleRedPay() {
window.open(redirectUrl.value)
}
// 清理所有资源
function cleanupResources() {
// 停止轮询
stopPolling()
// 清理倒计时
if (countdownRef.value) {
clearInterval(countdownRef.value)
countdownRef.value = null
}
// 清理其他资源
url_qrcode.value = ''
orderId.value = ''
active.value = false
}
async function handleOpenPayment() {
await getQrCode()
if (!timer) {
// 检查定时器是否已存在
timer = setInterval(() => {
queryOrderStatus()
}, POLL_INTERVAL)
}
// 启动倒计时
startCountdown()
}
// 清除定时器的函数
function stopPolling() {
if (timer) {
clearInterval(timer)
timer = null // 清除定时器后将变量设置为 null
}
}
// 监听visible变化处理资源
watch(
() => props.visible,
(newVal, oldVal) => {
if (newVal && !oldVal) {
// 变为可见时
active.value = true
handleOpenPayment()
} else if (!newVal && oldVal) {
// 变为不可见时
cleanupResources()
}
}
)
onMounted(() => {
if (props.visible) {
handleOpenPayment()
}
})
onBeforeUnmount(() => {
cleanupResources()
})
</script>
<template>
<div class="overflow-y-auto custom-scrollbar p-2" :class="{ 'max-h-[70vh]': !isMobile }">
<div
class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 mb-4"
>
<!-- 卡片标题 -->
<div class="flex items-center mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<button @click="backToMainView" class="btn-icon btn-md mr-2">
<ArrowLeft size="18" />
</button>
<div class="text-base font-semibold text-gray-900 dark:text-gray-100">
{{ t('pay.productPayment') }}
</div>
</div>
<div class="p-2">
<div>
<span class="whitespace-nowrap font-bold">{{ t('pay.amountDue') }}</span>
<span class="ml-1 text-xl font-bold tracking-tight">{{
`${orderInfo.pkgInfo?.price}`
}}</span>
</div>
<div class="mt-2 flex">
<span class="whitespace-nowrap font-bold">{{ t('pay.packageName') }}</span
><span class="ml-2"> {{ orderInfo.pkgInfo?.name }}</span>
</div>
<div
class="flex justify-center"
:class="[isMobile ? 'flex-col' : 'flex-row', isRedirectPay ? 'flex-row-reverse' : '']"
>
<div>
<div class="flex items-center justify-center my-3 relative">
<!-- 微信登录风格的加载动画 -->
<div
v-if="qrCodeloading && !isRedirectPay"
class="absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2"
>
<div
class="animate-spin rounded-full h-10 w-10 border-b-2 border-primary-600 dark:border-primary-400"
></div>
</div>
<div
v-if="qrCodeloading"
class="w-[240px] h-[240px] rounded-lg bg-gray-200 dark:bg-gray-700 animate-pulse"
></div>
<!-- epay -->
<QRCode
v-if="
payPlatform === 'epay' && !qrCodeloading && !redirectloading && !isRedirectPay
"
:value="url_qrcode"
:size="240"
/>
<QRCode
v-if="
payPlatform === 'dulu' && !qrCodeloading && !redirectloading && !isRedirectPay
"
:value="url_qrcode"
:size="240"
/>
<img
v-if="payType === 'wxpay' && !qrCodeloading && !isRedirectPay"
:src="wxpay"
class="absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2 w-10 bg-[#fff]"
/>
<img
v-if="payType === 'alipay' && !qrCodeloading && !isRedirectPay"
:src="alipay"
class="absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2 w-10 bg-[#fff]"
/>
<!-- wechat -->
<QRCode
v-if="payPlatform === 'wechat' && !qrCodeloading"
:value="url_qrcode"
:size="240"
/>
<div
v-if="isRedirectPay"
class="flex flex-col"
:class="[isRedirectPay && isMobile ? 'ml-0' : 'ml-20']"
>
<span class="mb-10 mt-5 text-base">{{ t('pay.siteAdminEnabledRedirect') }}</span>
<!-- mapy 跳转支付 -->
<button
v-if="isRedirectPay"
type="button"
class="btn btn-primary btn-md"
:disabled="redirectloading"
@click="handleRedPay"
>
{{ t('pay.clickToPay') }}
</button>
</div>
<!-- dulu -->
<!-- <iframe
v-if="payPlatform === 'dulu' && !redirectloading"
class="w-[280px] h-[280px] scale-90"
:src="url_qrcode"
frameborder="0"
/> -->
<!-- hupi -->
<iframe
v-if="payPlatform === 'hupi' && !redirectloading"
class="w-[280px] h-[280px] scale-90"
:src="url_qrcode"
frameborder="0"
/>
<!-- ltzf -->
<img
v-if="payPlatform === 'ltzf' && !redirectloading"
:src="url_qrcode"
class="w-[280px] h-[280px] scale-90"
alt="QRCode"
/>
</div>
<span v-if="!isRedirectPay" class="flex items-center justify-center text-lg">
{{ t('pay.open') }} {{ plat }} {{ t('pay.scanToPay') }}
</span>
</div>
<div class="flex flex-col" :class="[isMobile ? 'w-full ' : ' ml-10 w-[200] ']">
<div
class="flex items-center justify-center mt-6 w-full font-bold text-sm"
:class="[isMobile ? 'mb-2' : 'mb-10']"
style="white-space: nowrap"
>
<span>{{ t('pay.completePaymentWithin') }}</span>
<span class="inline-block w-16 text-primary-500 text-center">
{{ remainingTime }}秒
</span>
<span>{{ t('pay.timeToCompletePayment') }}</span>
</div>
<!-- 支付方式选择区域 -->
<div class="mt-6 space-y-6">
<div v-for="pay in payTypes" :key="pay.value" class="flex items-center">
<input
type="radio"
:id="pay.value"
name="payment-method"
:value="pay.value"
v-model="payType"
class="h-4 w-4 border-gray-300 text-indigo-600 focus:ring-indigo-600"
/>
<label
:for="pay.value"
class="ml-3 block text-sm font-medium leading-6 text-gray-900 dark:text-gray-300"
>
<img class="h-4 object-contain mr-2 inline-block" :src="pay.icon" alt="" />
{{ pay.label }}
</label>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(155, 155, 155, 0.5) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(155, 155, 155, 0.5);
border-radius: 20px;
border: transparent;
}
/* 暗黑模式下滚动条样式 */
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(100, 100, 100, 0.5);
}
.dark .custom-scrollbar {
scrollbar-color: rgba(100, 100, 100, 0.5) transparent;
}
</style>

View File

@@ -0,0 +1,100 @@
<script setup lang="ts">
import { useBasicLayout } from '@/hooks/useBasicLayout'
import { useAppStore, useAuthStore } from '@/store'
import { MdPreview } from 'md-editor-v3'
import 'md-editor-v3/lib/preview.css'
import { computed, onMounted, watch } from 'vue'
const authStore = useAuthStore()
const appStore = useAppStore()
const darkMode = computed(() => appStore.theme === 'dark')
const { isMobile } = useBasicLayout()
const { noticeInfo } = authStore.globalConfig
interface Props {
visible: boolean
}
const props = defineProps<Props>()
const globalConfig = computed(() => authStore.globalConfig)
function openDrawerAfter() {
// 刷新全局配置数据,确保获取最新的公告信息
authStore.getGlobalConfig().catch(error => {
console.error('获取最新公告信息失败:', error)
})
}
watch(
() => props.visible,
isVisible => {
if (isVisible) {
// 当组件变为可见时刷新数据
openDrawerAfter()
}
}
)
onMounted(() => {
if (props.visible) {
openDrawerAfter()
}
})
</script>
<template>
<div class="overflow-y-auto custom-scrollbar p-1" :class="{ 'max-h-[70vh]': !isMobile }">
<!-- 公告信息卡片 -->
<div
class="p-4 bg-white dark:bg-gray-700 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 mb-4 flex flex-col space-y-4"
>
<!-- 卡片标题 -->
<div
class="text-base font-semibold text-gray-900 dark:text-gray-100 pb-2 border-b border-gray-200 dark:border-gray-700"
>
{{ globalConfig.noticeTitle || '平台公告' }}
</div>
<!-- 公告内容 -->
<div class="overflow-y-auto" :class="{ 'max-h-[calc(70vh-120px)]': !isMobile }">
<MdPreview
editorId="preview-only"
:modelValue="noticeInfo"
:theme="darkMode ? 'dark' : 'light'"
class="dark:bg-gray-700 w-full"
/>
</div>
</div>
</div>
</template>
<style scoped>
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(155, 155, 155, 0.5) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(155, 155, 155, 0.5);
border-radius: 20px;
border: transparent;
}
/* 暗黑模式下滚动条样式 */
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(100, 100, 100, 0.5);
}
.dark .custom-scrollbar {
scrollbar-color: rgba(100, 100, 100, 0.5) transparent;
}
</style>

View File

@@ -0,0 +1,78 @@
<script setup lang="ts">
import { useBasicLayout } from '@/hooks/useBasicLayout'
import { useAppStore, useAuthStore } from '@/store'
import { MdPreview } from 'md-editor-v3'
import 'md-editor-v3/lib/preview.css'
import { computed } from 'vue'
interface Props {
visible: boolean
}
defineProps<Props>()
const authStore = useAuthStore()
const appStore = useAppStore()
const darkMode = computed(() => appStore.theme === 'dark')
const globalConfig = computed(() => authStore.globalConfig)
const { isMobile } = useBasicLayout()
</script>
<template>
<div class="overflow-y-auto custom-scrollbar p-1" :class="{ 'max-h-[70vh]': !isMobile }">
<!-- 用户协议卡片 -->
<div
class="p-4 bg-white dark:bg-gray-700 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 mb-4 flex flex-col space-y-4"
>
<!-- 卡片标题 -->
<div
class="text-base font-semibold text-gray-900 dark:text-gray-100 pb-2 border-b border-gray-200 dark:border-gray-700"
>
{{ globalConfig.agreementTitle || '用户协议' }}
</div>
<!-- 用户协议内容 -->
<div
class="overflow-y-auto custom-scrollbar"
:class="{ 'max-h-[calc(70vh-160px)]': !isMobile }"
>
<MdPreview
editorId="preview-only"
:modelValue="globalConfig.agreementInfo"
:theme="darkMode ? 'dark' : 'light'"
class="dark:bg-gray-700 w-full"
/>
</div>
</div>
</div>
</template>
<style scoped>
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(155, 155, 155, 0.5) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(155, 155, 155, 0.5);
border-radius: 20px;
border: transparent;
}
/* 暗黑模式下滚动条样式 */
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(100, 100, 100, 0.5);
}
.dark .custom-scrollbar {
scrollbar-color: rgba(100, 100, 100, 0.5) transparent;
}
</style>

View File

@@ -0,0 +1,228 @@
<template>
<transition name="modal-fade">
<div
v-if="props.visible"
class="fixed inset-0 z-[9000] flex items-center justify-center bg-gray-900 bg-opacity-50"
>
<div
class="bg-white dark:bg-gray-750 rounded-lg shadow-lg flex flex-col"
:class="
isMobile ? 'w-full h-full' : 'h-[80vh] rounded-lg shadow-lg w-full max-w-5xl p-4 mx-2'
"
>
<!-- 标题部分 -->
<div class="flex justify-between items-center mb-2">
<span class="text-xl font-bold dark:text-white">设置</span>
<button @click="handleClose" class="btn-icon btn-md">
<Close size="20" />
</button>
</div>
<!-- 主体部分 -->
<div class="flex flex-grow">
<!-- 左边标签栏 -->
<div class="w-1/5 bg-white dark:bg-gray-750 rounded-lg">
<div
v-for="(tab, index) in tabs"
:key="index"
@click="switchTab(index)"
class="relative flex items-center gap-3 px-3 py-3 my-1 break-all rounded-lg cursor-pointer group dark:hover:bg-gray-700 font-medium text-sm"
:class="{
'bg-gray-50 text-primary-600 dark:bg-gray-700 dark:text-primary-400':
activeTab === index,
'text-gray-700 dark:text-gray-400': activeTab !== index,
}"
>
{{ tab.name }}
</div>
<!-- 添加退出登录按钮位于标签栏最下方 -->
<div class="mt-auto">
<div
@click="showLogoutConfirmation"
class="relative flex items-center gap-3 px-3 py-3 my-1 break-all rounded-lg cursor-pointer group font-medium text-sm text-red-500 dark:text-red-400 hover:bg-gray-50 dark:hover:bg-gray-700"
>
退出登录
</div>
</div>
</div>
<!-- 右边内容区域 -->
<div class="w-4/5 bg-white dark:bg-gray-750 rounded-lg ml-4">
<transition name="fade" mode="out-in">
<div v-if="!isTabSwitching" key="loaded-content">
<keep-alive>
<component
v-if="tabs[activeTab]"
:is="tabs[activeTab].component"
:key="activeKey"
:visible="props.visible && !isTabSwitching"
></component>
</keep-alive>
</div>
<div v-else key="loading-placeholder" class="flex justify-center items-center h-full">
<div class="animate-pulse flex space-x-4">
<div class="flex-1 space-y-4 py-1">
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
<div class="space-y-2">
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
</div>
</div>
</div>
</div>
</transition>
</div>
</div>
</div>
</div>
</transition>
</template>
<script setup lang="ts">
import { useBasicLayout } from '@/hooks/useBasicLayout'
import { useAuthStore, useGlobalStoreWithOut } from '@/store'
import { dialog } from '@/utils/dialog'
import { Close } from '@icon-park/vue-next' // Only Close icon needed now
import { computed, markRaw, nextTick, onMounted, ref, watch } from 'vue'
import AccountManagement from './Settings/AccountManagement.vue'
import MemberCenter from './Settings/MemberCenter.vue'
import NoticeDialog from './Settings/NoticeDialog.vue'
import UserAgreement from './Settings/UserAgreement.vue'
const useGlobalStore = useGlobalStoreWithOut()
interface Props {
visible: boolean
}
const { isMobile } = useBasicLayout() // Still needed for :class binding
const props = defineProps<Props>()
const authStore = useAuthStore()
const globalConfig = computed(() => authStore.globalConfig)
// Use markRaw to prevent components from becoming reactive
const tabs = computed(() => {
const baseTabs = [
{ name: '账户管理', component: markRaw(AccountManagement) },
{ name: '会员中心', component: markRaw(MemberCenter) },
// { name: '数据管理', component: markRaw(DataManagement) },
{ name: '网站公告', component: markRaw(NoticeDialog) },
]
// 只有当 globalConfig.isAutoOpenAgreement === '1' 时才添加用户协议选项
if (globalConfig.value.isAutoOpenAgreement === '1') {
baseTabs.push({ name: '用户协议', component: markRaw(UserAgreement) })
}
return baseTabs
})
// Desktop-specific state
const activeTab = ref(
useGlobalStore.settingsActiveTab >= 0 && useGlobalStore.settingsActiveTab < tabs.value.length
? useGlobalStore.settingsActiveTab
: 0
)
const activeKey = ref(Date.now())
const isTabSwitching = ref(false)
// Tab switching function (Desktop only)
function switchTab(index: number) {
if (index >= 0 && index < tabs.value.length && index !== activeTab.value) {
isTabSwitching.value = true
activeTab.value = index
useGlobalStore.settingsActiveTab = index
nextTick(() => {
activeKey.value = Date.now()
setTimeout(() => {
isTabSwitching.value = false
}, 50) // Reduced delay
})
}
}
// Watch for global changes (for desktop sync)
watch(
() => useGlobalStore.settingsActiveTab,
newVal => {
// Check if the dialog is visible and it's not mobile view
if (
props.visible &&
!isMobile.value &&
newVal >= 0 &&
newVal < tabs.value.length &&
newVal !== activeTab.value
) {
switchTab(newVal)
}
}
)
// Watch for visibility changes (for desktop refresh)
watch(
() => props.visible,
isVisible => {
if (isVisible && !isMobile.value) {
// Sync with global store on open for desktop
const targetTab =
useGlobalStore.settingsActiveTab >= 0 &&
useGlobalStore.settingsActiveTab < tabs.value.length
? useGlobalStore.settingsActiveTab
: 0
if (activeTab.value !== targetTab) {
activeTab.value = targetTab
}
activeKey.value = Date.now() // Refresh key on open
isTabSwitching.value = false // Ensure not stuck loading
} else if (!isVisible) {
// Optionally reset tab when closed, or keep last state?
// activeTab.value = 0;
isTabSwitching.value = false
}
},
{ immediate: true } // Run immediately to set initial state
)
// Close Handler
function handleClose() {
useGlobalStore.updateSettingsDialog(false)
}
// Logout Handler
function showLogoutConfirmation() {
const dialogInstance = dialog()
dialogInstance.warning({
title: '退出登录',
content: '确定要退出登录吗?',
positiveText: '确认',
negativeText: '取消',
onPositiveClick: () => {
authStore.logOut()
handleClose() // Close settings after logout
},
})
}
// onMounted: Initial state sync handled by immediate watchers
onMounted(() => {
// console.log('Desktop SettingsDialog mounted');
})
</script>
<style scoped>
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.5s;
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,120 @@
<script lang="ts" setup>
interface Props {
// 是否激活状态
active?: boolean
// 是否禁用
disabled?: boolean
// 是否显示分割线
divider?: boolean
// 图标支持URL或组件
icon?: string
// 菜单项标题
title?: string
// 菜单项描述
description?: string
// 自定义样式类
className?: string
// 是否显示右箭头
showArrow?: boolean
// 菜单项尺寸
size?: 'sm' | 'md' | 'lg'
}
const props = withDefaults(defineProps<Props>(), {
active: false,
disabled: false,
divider: false,
icon: '',
title: '',
description: '',
className: '',
showArrow: false,
size: 'md',
})
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
function handleClick(event: MouseEvent) {
if (!props.disabled) {
emit('click', event)
}
}
</script>
<template>
<!-- 分割线 -->
<div v-if="divider" class="menu-divider" role="separator" />
<!-- 菜单项 -->
<div
v-else
:class="[
'menu-item',
`menu-item-${size}`,
{
'menu-item-active': active,
'menu-item-disabled': disabled,
},
className,
]"
@click="handleClick"
role="menuitem"
:tabindex="disabled ? -1 : 0"
:aria-disabled="disabled"
>
<!-- 图标区域 -->
<div v-if="icon || $slots.icon" class="menu-item-icon">
<slot name="icon">
<img
v-if="icon"
:src="icon"
:alt="`${title}图标`"
class="w-full h-full object-cover rounded-full"
/>
</slot>
</div>
<!-- 内容区域 -->
<div class="menu-item-content">
<slot>
<div v-if="title" class="menu-item-title">
{{ title }}
</div>
<div v-if="description" class="menu-item-description">
{{ description }}
</div>
</slot>
</div>
<!-- 右侧内容 -->
<div v-if="$slots.suffix || showArrow || active" class="flex-shrink-0">
<slot name="suffix">
<!-- 激活状态的勾选图标 -->
<svg
v-if="active"
class="w-4 h-4 text-primary-600 dark:text-primary-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/>
</svg>
<!-- 右箭头 -->
<svg
v-else-if="showArrow"
class="w-4 h-4 text-gray-400 dark:text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</slot>
</div>
</div>
</template>

View File

@@ -0,0 +1,5 @@
import DropdownMenu from './index.vue'
import MenuItem from './MenuItem.vue'
export { DropdownMenu, MenuItem }
export default DropdownMenu

View File

@@ -0,0 +1,280 @@
<script lang="ts" setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
// 定义位置类型添加auto选项
type Position =
| 'bottom-left'
| 'bottom-right'
| 'bottom-center'
| 'top-left'
| 'top-right'
| 'top-center'
| 'auto'
interface Props {
// 菜单是否打开
modelValue?: boolean
// 触发器内容插槽名称
trigger?: string
// 菜单位置
position?: Position
// 最大高度
maxHeight?: string
// 最小宽度
minWidth?: string
// 是否禁用
disabled?: boolean
// 自定义菜单样式类
menuClass?: string
// 自定义触发器样式类
triggerClass?: string
// 点击外部是否关闭
closeOnClickOutside?: boolean
// 按下ESC是否关闭
closeOnEscape?: boolean
// z-index值
zIndex?: number
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
position: 'bottom-left',
maxHeight: '60vh',
minWidth: '200px',
disabled: false,
menuClass: '',
triggerClass: '',
closeOnClickOutside: true,
closeOnEscape: true,
zIndex: 50,
})
const emit = defineEmits<{
'update:modelValue': [value: boolean]
open: []
close: []
}>()
const isOpen = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
})
const menuRef = ref<HTMLElement>()
const triggerRef = ref<HTMLElement>()
// 自动检测位置的响应式变量
const autoPosition = ref<Exclude<Position, 'auto'>>('bottom-left')
// 检测位置函数
const detectPosition = () => {
if (props.position !== 'auto' || !triggerRef.value) return
const triggerRect = triggerRef.value.getBoundingClientRect()
const viewportHeight = window.innerHeight
// 计算触发器相对于视口的垂直位置
const triggerCenterY = triggerRect.top + triggerRect.height / 2
// 只检测垂直方向:如果触发器在屏幕上半部分,向下展开;否则向上展开
const isTopHalf = triggerCenterY < viewportHeight / 2
const verticalDirection = isTopHalf ? 'bottom' : 'top'
// 水平方向固定使用right对齐
const horizontalDirection = 'right'
// 组合最终位置
const newPosition = `${verticalDirection}-${horizontalDirection}` as Exclude<Position, 'auto'>
autoPosition.value = newPosition
// 添加调试信息(开发时可用)
if (process.env.NODE_ENV === 'development') {
console.log('Auto position detection:', {
triggerRect,
viewportHeight,
triggerCenterY,
isTopHalf,
verticalDirection,
horizontalDirection,
finalPosition: newPosition,
})
}
}
// 计算最终位置
const finalPosition = computed(() => {
if (props.position === 'auto') {
return autoPosition.value
}
return props.position || 'bottom-left'
})
// 计算位置样式类使用finalPosition而不是props.position
const positionClasses = computed(() => {
const position = finalPosition.value
const classes = {
'bottom-left': 'menu-items-bottom',
'bottom-right': 'menu-items-bottom menu-items-right-aligned',
'bottom-center': 'menu-items-bottom menu-items-center',
'top-left': 'menu-items-top',
'top-right': 'menu-items-top menu-items-right-aligned',
'top-center': 'menu-items-top menu-items-center',
}
return classes[position] || classes['bottom-left']
})
// 菜单样式类 - 使用全局CSS类
const menuClasses = computed(() => {
const baseClasses = ['menu-items', 'custom-scrollbar', positionClasses.value, props.menuClass]
return baseClasses.filter(Boolean).join(' ')
})
// 切换菜单状态
const toggleMenu = () => {
if (props.disabled) return
isOpen.value = !isOpen.value
}
// 打开菜单
function open() {
if (props.disabled) return
isOpen.value = true
}
// 关闭菜单
function close() {
isOpen.value = false
}
// 处理点击外部关闭菜单
function handleClickOutside(event: MouseEvent) {
if (!props.closeOnClickOutside || !isOpen.value) return
const target = event.target as Node
const menuElement = menuRef.value
const triggerElement = triggerRef.value
if (
menuElement &&
triggerElement &&
!menuElement.contains(target) &&
!triggerElement.contains(target)
) {
close()
}
}
// 处理ESC键关闭
function handleKeyDown(event: KeyboardEvent) {
if (props.closeOnEscape && event.key === 'Escape' && isOpen.value) {
close()
}
}
// 监听菜单打开状态变化
watch(isOpen, (newValue, oldValue) => {
if (newValue && !oldValue) {
// 菜单从关闭变为打开时,重新检测位置
if (props.position === 'auto') {
// 使用nextTick确保DOM更新完成
nextTick(() => {
detectPosition()
})
}
emit('open')
} else if (!newValue && oldValue) {
emit('close')
}
})
// 生命周期钩子
onMounted(() => {
if (props.closeOnClickOutside) {
document.addEventListener('click', handleClickOutside)
}
if (props.closeOnEscape) {
window.addEventListener('keydown', handleKeyDown)
}
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside)
window.removeEventListener('keydown', handleKeyDown)
})
// 暴露方法给父组件
defineExpose({
open,
close,
toggle: toggleMenu,
})
</script>
<template>
<div class="menu relative">
<!-- 触发器 -->
<div
ref="triggerRef"
:class="['cursor-pointer', triggerClass, { 'opacity-50 cursor-not-allowed': disabled }]"
@click="toggleMenu"
role="button"
:aria-expanded="isOpen"
:aria-haspopup="true"
:disabled="disabled"
>
<slot name="trigger" :isOpen="isOpen" :disabled="disabled">
<button
type="button"
class="menu-button flex items-center px-3 py-2 text-sm font-medium rounded-lg bg-transparent hover:bg-gray-50 dark:hover:bg-gray-750 text-gray-600 dark:text-gray-400"
:disabled="disabled"
>
<span>点击展开菜单</span>
<svg
class="ml-2 w-4 h-4 transition-transform duration-200"
:class="{ 'rotate-180': isOpen }"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
</slot>
</div>
<!-- 菜单内容 -->
<transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<div
v-show="isOpen"
ref="menuRef"
:class="menuClasses"
:style="{
maxHeight: maxHeight,
minWidth: minWidth,
zIndex: zIndex,
overflowY: 'auto',
}"
role="menu"
:aria-hidden="!isOpen"
>
<slot name="menu" :close="close" :isOpen="isOpen">
<div>
<div class="px-4 py-2 text-sm text-gray-700 dark:text-gray-300">请添加菜单内容</div>
</div>
</slot>
</div>
</transition>
</div>
</template>

View File

@@ -0,0 +1,19 @@
<template>
<ImageViewer
v-model:visible="isVisible"
:image-url="currentImageUrl"
:file-name="currentFileName"
@close="handleClose"
/>
</template>
<script setup lang="ts">
import ImageViewer from './index.vue'
import { useImageViewer } from './useImageViewer'
const { isVisible, currentImageUrl, currentFileName, closeImageViewer } = useImageViewer()
function handleClose() {
closeImageViewer()
}
</script>

View File

@@ -0,0 +1,490 @@
<template>
<Teleport to="body">
<div
v-if="visible"
class="fixed inset-0 z-[9999] flex items-center justify-center bg-black bg-opacity-90 backdrop-blur-sm"
@click="handleBackgroundClick"
@wheel.prevent="handleWheel"
>
<!-- 工具栏 -->
<div
class="absolute top-4 left-1/2 transform -translate-x-1/2 z-10 flex items-center space-x-2 bg-black bg-opacity-50 rounded-lg px-4 py-2"
>
<!-- 缩小 -->
<button
class="toolbar-btn"
@click="zoomOut"
:disabled="scale <= minScale"
title="缩小 (Ctrl + -)"
>
<Minus size="20" />
</button>
<!-- 缩放比例显示 -->
<span class="text-white text-sm min-w-[60px] text-center">
{{ Math.round(scale * 100) }}%
</span>
<!-- 放大 -->
<button
class="toolbar-btn"
@click="zoomIn"
:disabled="scale >= maxScale"
title="放大 (Ctrl + +)"
>
<Plus size="20" />
</button>
<!-- 分割线 -->
<div class="w-px h-6 bg-gray-400"></div>
<!-- 逆时针旋转 -->
<button class="toolbar-btn" @click="rotateLeft" title="逆时针旋转 (Ctrl + ←)">
<span class="text-lg"></span>
</button>
<!-- 顺时针旋转 -->
<button class="toolbar-btn" @click="rotateRight" title="顺时针旋转 (Ctrl + →)">
<span class="text-lg"></span>
</button>
<!-- 分割线 -->
<div class="w-px h-6 bg-gray-400"></div>
<!-- 重置 -->
<button class="toolbar-btn" @click="reset" title="重置 (Ctrl + 0)">
<Refresh size="20" />
</button>
<!-- 保存 -->
<button class="toolbar-btn" @click="save" title="保存 (Ctrl + S)">
<Download size="20" />
</button>
</div>
<!-- 关闭按钮 -->
<button
class="absolute top-4 right-4 z-10 p-2 rounded-full bg-black bg-opacity-50 text-white hover:bg-opacity-70 transition-all duration-200"
@click="close"
title="关闭 (ESC)"
>
<Close size="24" />
</button>
<!-- 图片容器 -->
<div
ref="imageContainer"
class="relative w-full h-full flex items-center justify-center overflow-hidden cursor-grab"
:class="{ 'cursor-grabbing': isDragging }"
@mousedown="startDrag"
@mousemove="drag"
@mouseup="stopDrag"
@mouseleave="stopDrag"
>
<!-- 图片 -->
<img
ref="imageRef"
:src="imageUrl"
class="max-w-none max-h-none transition-transform duration-300 ease-out select-none"
:style="imageStyle"
alt="预览图片"
@load="handleImageLoad"
@error="handleImageError"
@dragstart.prevent
/>
<!-- 加载状态 -->
<div v-if="loading" class="absolute inset-0 flex items-center justify-center">
<div class="text-white text-lg">加载中...</div>
</div>
<!-- 错误状态 -->
<div v-if="error" class="absolute inset-0 flex items-center justify-center">
<div class="text-white text-lg">图片加载失败</div>
</div>
</div>
<!-- 底部提示 -->
<div
class="absolute bottom-4 left-1/2 transform -translate-x-1/2 text-white text-sm bg-black bg-opacity-50 px-3 py-1 rounded-full"
>
拖拽移动 滚轮缩放 ESC 关闭
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { Close, Download, Minus, Plus, Refresh } from '@icon-park/vue-next'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
interface Props {
visible: boolean
imageUrl: string
fileName?: string
}
interface Emits {
(e: 'update:visible', value: boolean): void
(e: 'close'): void
}
const props = withDefaults(defineProps<Props>(), {
fileName: 'image',
})
const emit = defineEmits<Emits>()
// 图片状态
const imageRef = ref<HTMLImageElement>()
const imageContainer = ref<HTMLDivElement>()
const loading = ref(true)
const error = ref(false)
// 变换状态
const scale = ref(1)
const rotation = ref(0)
const translateX = ref(0)
const translateY = ref(0)
// 缩放限制
const minScale = 0.1
const maxScale = 5
// 拖拽状态
const isDragging = ref(false)
const dragStart = ref({ x: 0, y: 0 })
const dragOffset = ref({ x: 0, y: 0 })
// 图片原始尺寸
const originalSize = ref({ width: 0, height: 0 })
// 计算图片样式
const imageStyle = computed(() => ({
transform: `translate(${translateX.value}px, ${translateY.value}px) scale(${scale.value}) rotate(${rotation.value}deg)`,
transformOrigin: 'center center',
}))
// 处理图片加载
function handleImageLoad() {
loading.value = false
error.value = false
if (imageRef.value) {
originalSize.value = {
width: imageRef.value.naturalWidth,
height: imageRef.value.naturalHeight,
}
// 自动适配屏幕尺寸
autoFit()
}
}
// 处理图片加载错误
function handleImageError() {
loading.value = false
error.value = true
}
// 自动适配屏幕尺寸
function autoFit() {
if (!imageRef.value || !imageContainer.value) return
const containerRect = imageContainer.value.getBoundingClientRect()
const imageWidth = originalSize.value.width
const imageHeight = originalSize.value.height
// 计算适合的缩放比例
const scaleX = (containerRect.width * 0.9) / imageWidth
const scaleY = (containerRect.height * 0.9) / imageHeight
const fitScale = Math.min(scaleX, scaleY, 1) // 不超过原始尺寸
scale.value = fitScale
}
// 放大
function zoomIn() {
const newScale = Math.min(scale.value * 1.2, maxScale)
scale.value = newScale
}
// 缩小
function zoomOut() {
const newScale = Math.max(scale.value / 1.2, minScale)
scale.value = newScale
}
// 顺时针旋转
function rotateRight() {
rotation.value = (rotation.value + 90) % 360
}
// 逆时针旋转
function rotateLeft() {
rotation.value = (rotation.value - 90 + 360) % 360
}
// 重置
function reset() {
scale.value = 1
rotation.value = 0
translateX.value = 0
translateY.value = 0
autoFit()
}
// 保存图片
async function save() {
if (!props.imageUrl) return
try {
console.log('全局预览器开始下载图片:', props.imageUrl)
// 尝试直接下载适用于同域或支持CORS的图片
try {
const response = await fetch(props.imageUrl, {
mode: 'cors',
credentials: 'omit',
})
console.log('全局预览器fetch响应状态:', response.status, response.statusText)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const blob = await response.blob()
console.log('全局预览器blob大小:', blob.size, 'blob类型:', blob.type)
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${props.fileName}.${getFileExtension(props.imageUrl)}`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
console.log('全局预览器图片下载完成')
return
} catch (fetchError) {
console.log('全局预览器fetch下载失败尝试canvas方法:', fetchError)
// 如果fetch失败尝试使用canvas方法适用于跨域图片
const img = new Image()
img.crossOrigin = 'anonymous'
await new Promise((resolve, reject) => {
img.onload = () => {
try {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
ctx?.drawImage(img, 0, 0)
canvas.toBlob(blob => {
if (blob) {
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${props.fileName}.${getFileExtension(props.imageUrl)}`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
console.log('全局预览器canvas下载完成')
resolve(true)
} else {
reject(new Error('无法生成图片blob'))
}
}, 'image/png')
} catch (canvasError) {
reject(canvasError)
}
}
img.onerror = () => {
reject(new Error('图片加载失败'))
}
img.src = props.imageUrl
})
}
} catch (error: any) {
console.error('保存图片失败:', error)
// 最后的备用方案:直接打开图片链接
try {
const a = document.createElement('a')
a.href = props.imageUrl
a.download = `${props.fileName}.${getFileExtension(props.imageUrl)}`
a.target = '_blank'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
console.log('全局预览器已在新窗口打开图片')
} catch (linkError) {
console.error('全局预览器所有下载方法都失败了:', linkError)
}
}
}
// 获取文件扩展名
function getFileExtension(url: string): string {
const match = url.match(/\.([^.]+)$/)
return match ? match[1] : 'png'
}
// 处理鼠标滚轮缩放
function handleWheel(event: WheelEvent) {
event.preventDefault()
const delta = event.deltaY > 0 ? -1 : 1
const zoomFactor = 1.1
const newScale =
delta > 0
? Math.min(scale.value * zoomFactor, maxScale)
: Math.max(scale.value / zoomFactor, minScale)
scale.value = newScale
}
// 开始拖拽
function startDrag(event: MouseEvent) {
if (event.button !== 0) return // 只响应左键
isDragging.value = true
dragStart.value = { x: event.clientX, y: event.clientY }
dragOffset.value = { x: translateX.value, y: translateY.value }
}
// 拖拽中
function drag(event: MouseEvent) {
if (!isDragging.value) return
const deltaX = event.clientX - dragStart.value.x
const deltaY = event.clientY - dragStart.value.y
translateX.value = dragOffset.value.x + deltaX
translateY.value = dragOffset.value.y + deltaY
}
// 停止拖拽
function stopDrag() {
isDragging.value = false
}
// 处理背景点击
function handleBackgroundClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
close()
}
}
// 关闭预览
function close() {
emit('update:visible', false)
emit('close')
}
// 键盘事件处理
function handleKeyDown(event: KeyboardEvent) {
if (!props.visible) return
const { key, ctrlKey, metaKey } = event
const isCtrl = ctrlKey || metaKey
switch (key) {
case 'Escape':
close()
break
case '=':
case '+':
if (isCtrl) {
event.preventDefault()
zoomIn()
}
break
case '-':
if (isCtrl) {
event.preventDefault()
zoomOut()
}
break
case '0':
if (isCtrl) {
event.preventDefault()
reset()
}
break
case 'ArrowLeft':
if (isCtrl) {
event.preventDefault()
rotateLeft()
}
break
case 'ArrowRight':
if (isCtrl) {
event.preventDefault()
rotateRight()
}
break
case 's':
if (isCtrl) {
event.preventDefault()
save()
}
break
}
}
// 监听visible变化重置状态
watch(
() => props.visible,
newVisible => {
if (newVisible) {
loading.value = true
error.value = false
reset()
}
}
)
// 监听imageUrl变化
watch(
() => props.imageUrl,
() => {
if (props.visible) {
loading.value = true
error.value = false
reset()
}
}
)
onMounted(() => {
document.addEventListener('keydown', handleKeyDown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeyDown)
})
</script>
<style scoped>
.toolbar-btn {
@apply p-2 rounded-md text-white hover:bg-white hover:bg-opacity-20 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed;
}
.toolbar-btn:not(:disabled):hover {
@apply bg-white bg-opacity-20;
}
.toolbar-btn:not(:disabled):active {
@apply bg-white bg-opacity-30;
}
</style>

View File

@@ -0,0 +1,55 @@
import { App, ref } from 'vue'
import ImageViewer from './index.vue'
// 全局状态
const isVisible = ref(false)
const currentImageUrl = ref('')
const currentFileName = ref('image')
// 图片预览器实例
export interface ImageViewerOptions {
imageUrl: string
fileName?: string
}
// 打开图片预览器
export function openImageViewer(options: ImageViewerOptions) {
currentImageUrl.value = options.imageUrl
currentFileName.value = options.fileName || 'image'
isVisible.value = true
}
// 关闭图片预览器
export function closeImageViewer() {
isVisible.value = false
currentImageUrl.value = ''
currentFileName.value = 'image'
}
// 图片预览器状态
export function useImageViewer() {
return {
isVisible,
currentImageUrl,
currentFileName,
openImageViewer,
closeImageViewer,
}
}
// 全局安装插件
export default {
install(app: App) {
// 注册全局组件
app.component('ImageViewer', ImageViewer)
// 注册全局方法
app.config.globalProperties.$imageViewer = {
open: openImageViewer,
close: closeImageViewer,
}
// 提供全局状态
app.provide('imageViewer', useImageViewer())
},
}

View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
import { useQRCode } from '@vueuse/integrations/useQRCode'
/*
参考文档https://vueuse.org/integrations/useQRCode/
https://www.npmjs.com/package/qrcode#qr-code-options
*/
interface Props {
value?: string // 扫描后的文本或地址
size?: number // 二维码大小
color?: string // 二维码颜色Value must be in hex format (十六进制颜色值)
backgroundColor?: string // 二维码背景色Value must be in hex format (十六进制颜色值)
bordered?: boolean // 是否有边框
borderColor?: string // 边框颜色
scale?: number // 每个black dots多少像素
/*
纠错等级也叫纠错率,就是指二维码可以被遮挡后还能正常扫描,而这个能被遮挡的最大面积就是纠错率。
通常情况下二维码分为 4 个纠错级别L级 可纠正约 7% 错误、M级 可纠正约 15% 错误、Q级 可纠正约 25% 错误、H级 可纠正约30% 错误。
并不是所有位置都可以缺损,像最明显的三个角上的方框,直接影响初始定位。中间零散的部分是内容编码,可以容忍缺损。
当二维码的内容编码携带信息比较少的时候,也就是链接比较短的时候,设置不同的纠错等级,生成的图片不会发生变化。
*/
errorLevel?: string // 二维码纠错等级
}
const props = withDefaults(defineProps<Props>(), {
value: '',
size: 160,
color: '#000',
backgroundColor: '#FFF',
bordered: true,
borderColor: '#0505050f',
scale: 8,
errorLevel: 'H', // 可选 L M Q H
})
// `qrcode` will be a ref of data URL
const qrcode = useQRCode(props.value, {
errorCorrectionLevel: props.errorLevel,
type: 'image/png',
quality: 1,
margin: 3,
scale: props.scale, // 8px per modules(black dots)
color: {
dark: props.color, // 像素点颜色
light: props.backgroundColor, // 背景色
},
})
</script>
<template>
<div
class="m-qrcode"
:class="{ bordered }"
:style="`width: ${size}px; height: ${size}px; border-color: ${borderColor};`"
>
<img :src="qrcode" class="u-qrcode" alt="QRCode" />
</div>
</template>
<style lang="less" scoped>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.m-qrcode {
display: inline-block;
border-radius: 8px;
overflow: hidden;
.u-qrcode {
width: 100%;
height: 100%;
}
}
.bordered {
border-width: 1px;
border-style: solid;
}
</style>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import { computed, useAttrs } from 'vue'
import { Icon } from '@iconify/vue'
interface Props {
icon?: string
}
defineProps<Props>()
const attrs = useAttrs()
const bindAttrs = computed<{ class: string; style: string }>(() => ({
class: (attrs.class as string) || '',
style: (attrs.style as string) || 'width: 1em, height: 1em',
}))
</script>
<template>
<Icon :icon="icon" v-bind="bindAttrs" />
</template>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import { useAuthStore } from '@/store'
import { computed } from 'vue'
const authStore = useAuthStore()
const isLogin = computed(() => authStore.isLogin)
const watermark = computed(() => {
// 已登录状态
const userId = authStore.userInfo.id
const nickname = authStore.userInfo.nickname
// 未登录状态
if (!isLogin.value) {
return `游客(${userId})`
}
// 如果有用户名格式username # 用户ID
if (nickname) {
return `${nickname}(${userId})`
}
// 如果没有用户名,格式:# 用户ID
return `(${userId})`
})
function createWatermark() {
if (!watermark.value) return ''
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) return ''
canvas.width = 240
canvas.height = 180
ctx.rotate((-20 * Math.PI) / 180)
ctx.fillStyle = 'rgba(0, 0, 0, 0.08)'
ctx.font = '16px Arial'
ctx.fillText(watermark.value, -20, 100)
return `url(${canvas.toDataURL()})`
}
</script>
<template>
<div
v-if="watermark"
class="fixed inset-0 pointer-events-none select-none z-50"
:style="{
backgroundImage: createWatermark(),
backgroundRepeat: 'repeat',
}"
/>
</template>

View File

@@ -0,0 +1,10 @@
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
export function useBasicLayout() {
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMobile = breakpoints.smaller('sm')
const isSmallMd = breakpoints.smaller('md')
const isSmallLg = breakpoints.smaller('lg')
const isSmallXl = breakpoints.smaller('xl')
return { isMobile, isSmallMd, isSmallLg, isSmallXl }
}

View File

@@ -0,0 +1,41 @@
import { useAppStore } from '@/store'
import { computed } from 'vue'
// 定义本地化配置
const locales = {
'en-US': {
locale: 'en-US',
name: 'English',
dateFormat: 'MM/DD/YYYY',
timeFormat: 'h:mm:ss A',
},
'zh-CN': {
locale: 'zh-CN',
name: '简体中文',
dateFormat: 'YYYY-MM-DD',
timeFormat: 'HH:mm:ss',
},
'zh-TW': {
locale: 'zh-TW',
name: '繁體中文',
dateFormat: 'YYYY-MM-DD',
timeFormat: 'HH:mm:ss',
},
}
export function useLanguage() {
const appStore = useAppStore()
// 计算当前语言配置
const currentLocale = computed(() => {
const lang = appStore.language
return locales[lang] || locales['zh-CN']
})
// 监听 appStore.language 的变化,并据此更新 Vue I18n 的语言环境
// watch(() => appStore.language, (newLocale) => {
// setLocale(newLocale);
// }, { immediate: true });
return { currentLocale }
}

View File

@@ -0,0 +1,26 @@
export function useTheme() {
const html = document.documentElement
const init = () => {
const saved = localStorage.getItem('theme')
if (saved && (saved === 'dark' || saved === 'light')) {
html.dataset.theme = saved
html.classList.toggle('dark', saved === 'dark')
} else {
// 检测系统主题偏好
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
html.dataset.theme = prefersDark ? 'dark' : 'light'
html.classList.toggle('dark', prefersDark)
localStorage.setItem('theme', html.dataset.theme)
}
}
const toggle = () => {
const next = html.dataset.theme === 'dark' ? 'light' : 'dark'
html.dataset.theme = next
html.classList.toggle('dark', next === 'dark')
localStorage.setItem('theme', next)
}
return { init, toggle }
}

596
chat/src/locales/en-US.json Normal file
View File

@@ -0,0 +1,596 @@
{
"language": "en",
"common": {
"add": "Add",
"addSuccess": "Add Success",
"edit": "Edit",
"editSuccess": "Edit Success",
"delete": "Delete",
"deleteSuccess": "Delete Success",
"save": "Save",
"saveSuccess": "Save Success",
"reset": "Reset",
"action": "Action",
"export": "Export",
"exportSuccess": "Export Success",
"import": "Import",
"importSuccess": "Import Success",
"clear": "Clear",
"clearSuccess": "Clear Success",
"yes": "Yes",
"no": "No",
"update": "Update",
"download": "Download",
"noData": "No Data",
"wrong": "Something went wrong, please try again later.",
"success": "Success",
"failed": "Failed",
"verify": "Verify",
"unauthorizedTips": "Unauthorized, please verify first.",
"confirm": "Confirm",
"cancel": "Cancel"
},
"chat": {
"newChatButton": "New Chat",
"placeholder": "Ask me anything...(Shift + Enter = line break)",
"placeholderMobile": "Ask me anything...",
"copy": "Copy",
"copied": "Copied",
"copyCode": "Copy Code",
"clearChat": "Clear Chat",
"clearChatConfirm": "Are you sure to clear this chat?",
"exportImage": "Export Image",
"exportImageConfirm": "Are you sure to export this chat to png?",
"exportSuccess": "Export Success",
"exportFailed": "Export Failed",
"usingContext": "Context Mode",
"turnOnContext": "In the current mode, sending messages will carry previous chat records.",
"turnOffContext": "In the current mode, sending messages will not carry previous chat records.",
"deleteMessage": "Delete Message",
"deleteMessageConfirm": "Are you sure to delete this message?",
"deleteHistoryConfirm": "Are you sure to clear this history?",
"clearHistoryConfirm": "Are you sure to clear chat history?",
"preview": "Preview",
"showRawText": "Show as raw text",
"size": "Size:",
"generatedContentDisclaimer": "AI can make mistakes. Consider checking important information. All rights reserved ©",
"square1": "Square [1:1]",
"illustration": "Illustration [4:3]",
"wallpaper": "Wallpaper [16:9]",
"media": "Media [3:4]",
"poster": "Poster [9:16]",
"square": "Square",
"landscape": "Landscape",
"portrait": "Portrait",
"chatDialogue": "Chat Dialogue",
"startNewConversationPrompt": "Click the button below to start a new conversation",
"newConversation": "New Conversation",
"networkModeEnabledContextInvalid": "Network mode enabled, context invalidated!",
"networkModeDisabled": "Network mode disabled!",
"pointsMall": "Points Mall",
"toggleTheme": "Toggle Theme",
"signInReward": "Sign-In Reward",
"networkMode": "Network Mode",
"searchHistoryConversations": "Search History Conversations",
"announcement": "Announcement",
"clear": "Clear",
"remaining": "",
"ordinaryPoints": "Ordinary Points",
"advancedPoints": "Advanced Points",
"drawingPoints": "Drawing Points",
"points": "Points",
"clearConversation": "Clear Conversation",
"clearAllNonFavoriteConversations": "Clear all non-favorite conversations?",
"more": "More",
"collapse": "Collapse",
"myApps": "My Apps",
"appSquare": "App Square",
"favorites": "Favorites",
"todayConversations": "Today",
"Conversations": "",
"historyConversations": "History",
"favoriteConversations": "Favorite",
"unfavorite": "Unfavorite",
"rename": "Rename",
"deleteConversation": "Delete",
"me": "You",
"onlineSearch": "Online Search",
"mindMap": "Mind Map",
"fileAnalysis": "File Analysis",
"delete": "Delete",
"regenerate": "Regenerate",
"pause": "Pause",
"loading": "Loading...",
"readAloud": "Read Aloud",
"vipCenter": "VIP Center",
"U1": "🔍 U1",
"U2": "🔍 U2",
"U3": "🔍 U3",
"U4": "🔍 U4",
"V1": "🪄 V1",
"V2": "🪄 V2",
"V3": "🪄 V3",
"V4": "🪄 V4",
"panLeft": "⬅️ Pan Left",
"panRight": "➡️ Pan Right",
"panUp": "⬆️ Pan Up",
"panDown": "⬇️ Pan Down",
"zoomIn15x": "↔️ Zoom 1.5x",
"zoomIn2x": "↔️ Zoom 2x",
"minorTransform": "Vary(Subtle)",
"strongTransform": "Vary(Strong)",
"enlargeImage": "Enlarge image {order}",
"transformImage": "Transform image {order}",
"expandDrawing": "Expand drawing",
"advancedTransform": "Advanced transform",
"translateImage": "Translate image",
"enlargeImagePrefix": "Enlarge image ",
"enlargeImageSuffix": "",
"transformImagePrefix": "Transform image ",
"transformImageSuffix": "",
"imageToImage": "Image to Image",
"faceConsistency": "Face Consistency",
"styleConsistency": "Style Consistency",
"selectAppOrTopic": "Select an application or topic for quick conversation"
},
"app": {
"sampleTemplate": "Sample Template",
"exploreInfinitePossibilities": "Explore infinite possibilities, create a smart future with AI",
"searchAppNameQuickFind": "Search app names, quick find applications...",
"allCategories": "All Categories",
"noModelConfigured": "No specific application model has been configured by the administrator, please contact them to set it up~"
},
"setting": {
"setting": "Setting",
"general": "General",
"advanced": "Advanced",
"config": "Config",
"avatarLink": "Avatar Link",
"name": "Name",
"description": "Description",
"role": "Role",
"resetUserInfo": "Reset UserInfo",
"chatHistory": "ChatHistory",
"theme": "Theme",
"language": "Language",
"api": "API",
"reverseProxy": "Reverse Proxy",
"timeout": "Timeout",
"socks": "Socks",
"httpsProxy": "HTTPS Proxy",
"balance": "API Balance",
"sign": "Signature"
},
"store": {
"siderButton": "Prompt Store",
"local": "Local",
"online": "Online",
"title": "Title",
"description": "Description",
"clearStoreConfirm": "Whether to clear the data?",
"importPlaceholder": "Please paste the JSON data here",
"addRepeatTitleTips": "Title duplicate, please re-enter",
"addRepeatContentTips": "Content duplicate: {msg}, please re-enter",
"editRepeatTitleTips": "Title conflict, please revise",
"editRepeatContentTips": "Content conflict {msg} , please re-modify",
"importError": "Key value mismatch",
"importRepeatTitle": "Title repeatedly skipped: {msg}",
"importRepeatContent": "Content is repeatedly skipped: {msg}",
"onlineImportWarning": "Note: Please check the JSON file source!",
"downloadError": "Please check the network status and JSON file validity"
},
"draw": {
"use": "Use",
"download": "Download",
"delete": "Delete",
"zoom": "Zoom:",
"U1": "U1",
"U2": "U2",
"U3": "U3",
"U4": "U4",
"regenerateOnce": "Regenerate",
"transform": "Transform:",
"V1": "V1",
"V2": "V2",
"V3": "V3",
"V4": "V4",
"pan": "Pan:",
"panLeft": "⬅️",
"panRight": "➡️",
"panUp": "⬆️",
"panDown": "⬇️",
"transformZoom": "Zoom Transform",
"zoom1_5x": "Zoom 1.5x",
"zoom2x": "Zoom 2x",
"minorTransform": "Minor Transform",
"strongTransform": "Strong Transform",
"regionalRedraw": "Regional Redraw",
"regionalRedraw1": "Regional Redraw (Select the Area to Change)",
"submitTask": "Submit Task",
"selectSuiteForZoom": "Action: Select a suite to zoom",
"selectSuiteForTransform": "Action: Select a suite for transformation",
"regeneratingImage": "Action: Regenerating the image",
"drawingInProgress": "Action: Rapid drawing in progress...",
"tryDifferentPrompt": "Execute: Try a different prompt!",
"statusWaiting": "Waiting",
"statusDrawing": "Drawing",
"statusSuccess": "Success",
"statusFailure": "Failure",
"statusTimeout": "Timeout",
"downloadImageTitle": "Download Image",
"downloadImageContent": "Download the current image",
"downloadButtonText": "Download",
"cancelButtonText": "Cancel",
"deleteRecordTitle": "Delete Record",
"deleteRecordContent": "Delete the current drawing record?",
"deleteButtonText": "Delete",
"submitZoomDrawingSuccess": "Zoom drawing task submitted successfully, please wait for it to finish!",
"submitRedrawSuccess": "Redraw task submitted successfully, please wait for it to finish!",
"submitTransformDrawingSuccess": "Transform drawing task submitted successfully, please wait for it to finish!",
"submitEnlargeDrawingSuccess": "Enlarge drawing task submitted successfully, please wait for it to finish!",
"submitAdvancedTransformDrawingSuccess": "Advanced transform drawing task submitted successfully, please wait for it to finish!",
"submitRegionalRedrawSuccess": "Regional redraw task submitted successfully, please wait for it to finish!",
"drawingRecordDeleted": "Drawing record has been deleted!",
"queueing": "Queueing...",
"drawing": "Drawing...",
"storing": "Storing image...",
"drawingFailed": "Drawing Failed",
"pointsRefunded": "Points Refunded!",
"submitDrawingTaskSuccess": "Drawing task submitted successfully, please wait for it to finish!",
"defaultStyle": "Default Style",
"expressiveStyle": "Expressive Style",
"cuteStyle": "Cute Style",
"scenicStyle": "Scenic Style",
"standardQuality": "Standard",
"generalQuality": "General",
"highDefinitionQuality": "High Definition",
"ultraHighDefinitionQuality": "Ultra High Definition",
"enterDescription": "Please enter descriptive words!",
"optimizationFailed": "Optimization failed!",
"professionalDrawing": "Professional Drawing",
"parameterExplanation": "Parameter Explanation: Generate image size ratio",
"imageSize": "Image Size",
"modelSelection": "Model Selection",
"tooltipMJ": "MJ: General-purpose realistic model",
"tooltipNIJI": "NIJI: Anime style, suitable for 2D models",
"version": "Version",
"style": "Style",
"parameters": "Parameters",
"parametersTooltip": "Use parameters wisely to achieve more ideal results!",
"quality": "Quality",
"chaos": "Chaos",
"chaosDescription": "Value range: 0-100, --chaos or --c",
"chaosExplanation": "Chaos level, can be understood as the space for AI to think outside the box",
"chaosAdvice": "The smaller the value, the more reliable, with the default of 0 being the most precise",
"stylization": "Stylization",
"stylizationDescription": "Stylization: --stylize or --s, range 1-1000",
"parameterExplanation1": "Parameter explanation: The higher the number, the richer and more artistic the visual presentation",
"setting": "Setting",
"carryParameters": "Carry Parameters",
"autoCarryParameters": "Whether to automatically carry parameters",
"carryOn": "On: Carries the parameters we have configured",
"carryOff": "Off: Uses the parameters we customize in the command",
"imageToImage": "Image to Image",
"clickOrDrag": "Click or drag an image here to use as input",
"supportFormats": "Supports PNG and JPG formats",
"remainingPoints": "Remaining Points",
"refresh": "Refresh",
"accountInfo": "Account Information",
"points": "Points",
"paintingSingleUse": "Painting:",
"imageGenerationSingleUse": "Generation:",
"enlargementSingleUse": "Enlargement:",
"submitDrawingTask": "Enter keywords, submit drawing task",
"optimize": "Optimize",
"enterDrawingKeywords": "Enter drawing keywords. For example: A colorful cat, cute, cartoon",
"unnecessaryElements": "Unnecessary Elements",
"exclusionPrompt": "Example: Generate a room image, but exclude the bed, you can fill in 'bed'!",
"workingContents": "Working Contents",
"currentTasks": "Current tasks in progress",
"goToAIDrawingSquare": "Click to go to the AI Drawing Square",
"tasksInProgress": "tasks are currently in progress. Please wait patiently for the drawing to complete. You can visit other pages and return later to see the results!",
"myDrawings": "My Drawings",
"aiDrawingSquare": "AI Drawing Square",
"sizeAdjustment": "Size Adjustment",
"keywordSearchPlaceholder": "Prompt Keyword Search"
},
"pay": {
"membershipMarket": "Membership Market",
"sizeAdjustment": "Size Adjustment",
"memberPackage": "Limited Time Member Package",
"permanentAddOnCard": "Permanent Add-On Card",
"baseModelQuota": "Base Model Quota",
"advancedModelQuota": "Advanced Model Quota",
"MJDrawingQuota": "MJ Drawing Quota",
"packageValidity": "Package Validity",
"days": "days",
"permanent": "Permanent",
"points": "Points",
"welcomeTipMobile": "Explore freely, welcome to our online store!",
"welcomeTipDesktop": "Explore freely, welcome to our online store, thank you for choosing us, let's start a delightful shopping journey together!",
"paymentNotEnabled": "Payment has not been enabled by the admin!",
"purchaseSuccess": "Purchase successful, enjoy your product!",
"paymentNotComplete": "You have not completed the payment yet!",
"wechat": "WeChat",
"alipay": "Alipay",
"wechatPay": "WeChat Pay",
"alipayPay": "Alipay Pay",
"paymentSuccess": "Congratulations, your payment was successful. Enjoy your purchase!",
"paymentTimeout": "Payment timeout, please place your order again!",
"productPayment": "Product Payment",
"amountDue": "Amount Due:",
"packageName": "Package Name:",
"packageDescription": "Package Description:",
"siteAdminEnabledRedirect": "The site administrator has enabled redirect payment",
"clickToPay": "Click to Proceed to Payment",
"completePaymentWithin": "Please complete the payment within",
"timeToCompletePayment": "!",
"open": "Open",
"scanToPay": "Scan to Pay"
},
"mindmap": {
"title": "Mind Map",
"yourNeeds": "Your Needs?",
"inputPlaceholder": "Please enter a brief description of the content you want to generate, AI will produce a complete markdown content and its mind map for you!",
"generateMindMapButton": "Generate Mind Map",
"contentRequirements": "Content Requirements",
"tryDemoButton": "Try a Demo",
"usageCredits": "Base credits per use: 1",
"exportHTML": "Export HTML",
"exportPNG": "Export PNG",
"exportSVG": "Export SVG"
},
"usercenter": {
"defaultSignature": "I am an AI robot based on deep learning and natural language processing technologies, aimed at providing users with efficient, accurate, and personalized intelligent services.",
"syncComplete": "Data synchronization completed",
"personalCenter": "Personal Center",
"logOut": "Log Out",
"myUsageRecord": "My Usage Record on This Site",
"basicModelCredits": "Basic Model Credits:",
"advancedModelCredits": "Advanced Model Credits:",
"basicModelUsage": "Basic Model Usage:",
"advancedModelUsage": "Advanced Model Usage:",
"drawingUsageCredits": "Drawing Usage Credits:",
"bindWeChat": "Bind WeChat:",
"clickToBindWeChat": "Click to Bind WeChat",
"weChatBound": "WeChat Bound",
"syncVisitorData": "Click to Sync Visitor Data",
"points": "Points",
"membershipExpiration": "Membership Expiration Date:",
"editInfoDescription": "Edit personal information, view more details",
"myDetails": "My Details",
"myWallet": "My Wallet",
"basicInfo": "Basic Information",
"userBasicSettings": "User Basic Settings",
"avatarPlaceholder": "Please enter your avatar URL",
"usernamePlaceholder": "Edit your username",
"signaturePlaceholder": "Edit your signature",
"passwordManagement": "Password Management",
"inviteBenefits": "Invite for Benefits",
"clickToLogin": "Log In",
"notLoggedIn": "Not Logged In",
"avatar": "Avatar",
"username": "Username",
"email": "Email",
"inviteeStatus": "Invitee Status",
"inviteTime": "Invite Time",
"rewardStatus": "Reward Status",
"certified": "Certified",
"notActivated": "Not Activated",
"rewardReceived": "Reward Received",
"waitingConfirmation": "Waiting for Confirmation",
"linkGeneratedSuccess": "Invitation link generated successfully",
"generateLinkFirst": "Please generate your exclusive invitation link first!",
"linkCopiedSuccess": "Exclusive invitation link copied successfully!",
"copyNotSupported": "Automatic copying is not supported on this device, please copy manually!",
"inviteForBenefits": "Invite Users, Earn Benefits!",
"myInviteCode": "My Invitation Code",
"generateInviteCode": "Generate Exclusive Invite Code",
"copyInviteLink": "Copy Exclusive Invite Link",
"inviteOneUser": "Inviting a user grants",
"basicModelCredits1": "basic model credits+",
"advancedModelCredits1": "advanced model credits+",
"mjDrawingCredits": "MJ drawing credits",
"receiveInvitation": "Invited users receive",
"creditsEnd": "credits",
"invitationRecord": "Invitation Record",
"passwordMinLength": "The minimum password length is 6 characters",
"passwordMaxLength": "The maximum password length is 30 characters",
"enterPassword": "Please enter a password",
"reenterPassword": "Please re-enter your password",
"passwordsNotMatch": "The passwords do not match",
"passwordUpdateSuccess": "Password updated successfully, please log in again!",
"changeYourPassword": "Change Your Password",
"oldPassword": "Old Password",
"newPassword": "New Password",
"confirmPassword": "Confirm Password",
"reloginAfterPasswordChange": "You will need to log in again after updating your password!",
"updateYourPassword": "Update Your Password",
"passwordRequirements": "Password Requirements",
"newPasswordInstructions": "To create a new password, you must meet all the following requirements:",
"minimumCharacters": "At least 6 characters",
"maximumCharacters": "No more than 30 characters",
"requireNumber": "Must contain at least one number",
"orderNumber": "Order Number",
"rechargeType": "Recharge Type",
"basicModelQuota": "Basic Model Quota",
"advancedModelQuota": "Advanced Model Quota",
"mjDrawingQuota": "MJ Drawing Quota",
"validity": "Validity",
"rechargeTime": "Recharge Time",
"enterCardSecret": "Please enter the card secret first!",
"cardRedeemSuccess": "Card redeemed successfully, enjoy your use!",
"userWalletBalance": "User Wallet Balance",
"basicModelBalance": "Basic Model Balance",
"creditUsageNote": "Each conversation consumes different credits depending on the model!",
"advancedModelBalance": "Advanced Model Balance",
"modelConsumptionNote": "Each conversation consumes different credits depending on the model!",
"mjDrawingBalance": "MJ Drawing Balance",
"drawingConsumptionNote": "Different credits are consumed based on drawing actions!",
"cardRecharge": "Card Recharge",
"enterCardDetails": "Please paste or enter your card details!",
"pleaseEnterCardDetails": "Please enter card details",
"exchange": "Exchange",
"buyCardSecret": "Buy Card Secret",
"rechargeRecords": "Recharge Records",
"packagePurchase": "Package Purchase",
"buyPackage": "Buy Package"
},
"siderBar": {
"signInReward": "Sign-in Reward",
"themeSwitch": "Theme Switch",
"personalCenter": "Personal Center",
"loginAccount": "Log In Account"
},
"notice": {
"doNotRemind24h": "Do not remind again for 24 hours"
},
"login": {
"enterUsername": "Please enter your username",
"usernameLength": "Username must be between 2 and 30 characters",
"enterPassword": "Please enter your password",
"passwordLength": "Password must be between 6 and 30 characters",
"enterEmail": "Please enter your email address",
"emailValid": "Please enter a valid email address",
"enterCaptcha": "Please enter the captcha",
"emailPhone": "Email / Phone",
"email": "Email",
"phone": "Phone",
"registrationSuccess": "Account registration successful, start your experience!",
"loginSuccess": "Account login successful, start your experience!",
"registerTitle": "Register",
"enterContact": "Please provide your ",
"enterCode": "Please enter the verification code",
"sendVerificationCode": "Send Verification Code",
"optionalInvitationCode": "Invitation Code [Optional]",
"registerAccount": "Register Account",
"alreadyHaveAccount": "Already have an account?",
"goToLogin": "Go to Login",
"password": "Password",
"enterYourPassword": "Please enter your password",
"rememberAccount": "Remember account",
"forgotPassword": "Forgot password?",
"loginAccount": "Log In Account",
"noAccount": "Don't have an account?",
"register": "Register",
"orUse": "or use",
"scanLogin": "Scan to Log In",
"wechatLogin": "WeChat Login",
"wechatScanFailed": "Failed WeChat QR code login? Use",
"useWechatScan": "Use WeChat to Scan and Log In"
},
"share": {
"orderAmount": "Order Amount",
"productType": "Product Type",
"status": "Status",
"commissionRate": "Commission Rate",
"commission": "Commission",
"orderTime": "Order Time",
"purchasePackage": "Purchase Package",
"accounted": "Accounted",
"generateInviteCodeSuccess": "Invitation code generated successfully",
"withdrawalTime": "Withdrawal Time",
"withdrawalAmount": "Withdrawal Amount",
"withdrawalChannel": "Withdrawal Channel",
"withdrawalStatus": "Withdrawal Status",
"withdrawalRemarks": "Withdrawal Remarks",
"auditor": "Auditor",
"alipay": "Alipay",
"wechat": "WeChat",
"paid": "Paid",
"rejected": "Rejected",
"inReview": "In Review",
"avatar": "Avatar",
"username": "Username",
"email": "Email",
"inviteeStatus": "Invitee Status",
"registered": "Registered",
"pendingActivation": "Pending Activation",
"registrationTime": "Registration Time",
"lastLogin": "Last Login",
"requestInviteCodeFirst": "Please request your invitation code first",
"linkCopiedSuccess": "Share link copied successfully",
"title": "Referral Program",
"description": "Join us and share in success! Welcome to our distribution page, become our partner and create a bright future together!",
"defaultSalesOutletName": "Rookie Referral Officer",
"myReferrals": "My Referrals",
"currencyUnit": "Yuan",
"remainingAmount": "Remaining Withdrawable Amount",
"withdrawingAmount": "Amount in Withdrawal",
"withdrawNow": "Withdraw Now",
"minimumWithdrawalPrefix": "Minimum",
"minimumWithdrawalSuffix": "Yuan Withdrawable",
"purchaseOrderCount": "Purchase Order Count",
"promotionLinkVisits": "Promotion Link Visits",
"registeredUsers": "Registered Users",
"referralEarnings": "Referral Earnings",
"referralEarningsDescription": "Commission amount returned after referred users register and buy products",
"percentage": "Percentage",
"applyForAdvancedAgent": "Apply to Become an Advanced Agent",
"contactAdminForAdvancedAgent": "Contact the site owner to apply for an advanced agent to enjoy high commissions",
"joinAsPartner": "Join Us as a Partner",
"partnerDescription": "Join us as a partner to co-operate the community, win-win cooperation!",
"winTogether": "Win Together, Advance Together",
"referralLink": "Referral Link:",
"apply": "Apply",
"referralRecordsTab": "Referral Records",
"withdrawalRecordsTab": "Withdrawal Records",
"registeredUsersTab": "Registered Users",
"inviteFriends": "Invite friends, gift meal cards, and enjoy recharge commissions!",
"inviteLink": "Invite Link",
"copy": "Copy",
"inviteBenefits1": "Both parties enjoy a certain amount of permanent card rewards when inviting friends.",
"inviteBenefits2Prefix": "Earn a ",
"inviteBenefits2Suffix": "% commission on your friend's recharge amount.",
"enterWithdrawalAmount": "Please enter your withdrawal amount!",
"selectWithdrawalChannel": "Please select your withdrawal channel!",
"enterContactInfo": "Please provide your contact information and remark!",
"optionalRemark": "If there are any special circumstances, please remark!",
"withdrawalSuccess": "Withdrawal application successful, please wait for approval!",
"withdrawalApplicationForm": "Withdrawal Application Form",
"contactInformation": "Contact Information",
"withdrawalRemark": "Withdrawal Remark",
"enterWithdrawalRemark": "Please enter your withdrawal remarks",
"applyWithdrawal": "Apply for Withdrawal"
},
"goods": {
"purchaseSuccess": "Purchase successful, enjoy your item!",
"paymentNotSuccessful": "You have not completed the payment yet!",
"orderConfirmationTitle": "Order Confirmation",
"orderConfirmationContent": "Welcome to purchase, are you sure you want to buy ",
"thinkAgain": "Let me think again",
"confirmPurchase": "Confirm Purchase",
"paymentNotEnabled": "Payment has not been enabled by the administrator!",
"selectProducts": "Select Products",
"basicModelQuota": "Basic Model Quota",
"advancedModelQuota": "Advanced Model Quota",
"drawingQuota": "Drawing Quota",
"buyPackage": "Buy Package"
},
"rechargeTypes": {
"1": "Registration Bonus",
"2": "Invitation Bonus",
"3": "Referring Others Bonus",
"4": "Purchase via Card Code",
"5": "Admin Bonus",
"6": "QR Code Purchase",
"7": "MJ Drawing Failure Refund",
"8": "Sign-in Reward"
},
"orderStatus": {
"0": "Not Paid",
"1": "Paid",
"2": "Payment Failed",
"3": "Payment Timeout"
},
"messages": {
"logoutSuccess": "Successfully logged out!"
}
}

50
chat/src/locales/index.ts Normal file
View File

@@ -0,0 +1,50 @@
import type { Language } from '@/store/modules/app/helper'
import type { App } from 'vue'
import { createI18n } from 'vue-i18n'
import enUS from './en-US.json'
import zhCN from './zh-CN.json'
// import zhTW from './zh-TW.json';
// const defaultLocale = savedLocale || appStore.language || 'zh-CN';
// const defaultLocale = 'en-US'
const defaultLocale = 'zh-CN'
const i18n = createI18n({
legacy: false, // 设置为false启用Composition API模式
globalInjection: true, // 启用全局注入
locale: defaultLocale,
fallbackLocale: 'en-US',
allowComposition: true,
messages: {
'en-US': enUS,
'zh-CN': zhCN,
// 'zh-TW': zhTW,
},
})
// 导出t函数以便在组件外部使用
export function t(key: string) {
return i18n.global.t(key)
}
export function setLocale(locale: Language) {
// 由于i18n.global.locale.value只接受特定字符串类型需要进行类型断言
i18n.global.locale.value = locale as 'zh-CN' | 'en-US'
// 将新的语言设置保存到 localStorage
localStorage.setItem('appLanguage', locale)
}
// 使用加密和指定过期时间(或永久保存)保存语言设置
// export function setLocale(locale: Language) {
// console.log(`正在切换语言至: ${locale}`);
// i18n.global.locale = locale;
// // 使用自定义 localStorage 工具保存语言设置
// ls.set('appLanguage', locale);
// console.log(`当前语言已切换至: ${i18n.global.locale}`);
// }
export function setupI18n(app: App) {
app.use(i18n)
}
export default i18n

585
chat/src/locales/zh-CN.json Normal file
View File

@@ -0,0 +1,585 @@
{
"language": "中文",
"common": {
"add": "添加",
"addSuccess": "添加成功",
"edit": "编辑",
"editSuccess": "编辑成功",
"delete": "删除",
"deleteSuccess": "删除成功",
"update": "修改",
"saveSuccess": "保存成功",
"updateUserSuccess": "修改用户信息成功",
"reset": "重置",
"action": "操作",
"export": "导出",
"exportSuccess": "导出成功",
"import": "导入",
"importSuccess": "导入成功",
"clear": "清空",
"clearSuccess": "清空成功",
"yes": "是",
"no": "否",
"download": "下载",
"noData": "暂无数据",
"wrong": "好像出错了,请稍后再试。",
"success": "操作成功",
"failed": "操作失败",
"verify": "验证",
"unauthorizedTips": "未经授权,请先进行验证。",
"confirm": "确认",
"cancel": "取消"
},
"chat": {
"newChatButton": "新建聊天",
"placeholder": "来说点什么吧...Shift + Enter = 换行)",
"placeholderMobile": "来说点什么...",
"copy": "复制",
"copied": "复制成功",
"copyCode": "复制",
"clearChat": "清空会话",
"clearChatConfirm": "是否清空会话?",
"exportImage": "保存会话到图片",
"exportImageConfirm": "是否将会话保存为图片?",
"exportSuccess": "保存成功",
"exportFailed": "保存失败",
"deleteMessage": "删除消息",
"deleteMessageConfirm": "删除此条对话?",
"deleteHistoryConfirm": "确定删除此记录?",
"deleteSuccess": "删除成功",
"clearHistoryConfirm": "确定清空聊天记录?",
"preview": "预览",
"showRawText": "显示原文",
"size": "尺寸:",
"generatedContentDisclaimer": "AI 生成内容仅供参考,不代表本平台立场。版权所有 ©",
"square1": "方形1:1",
"illustration": "配图4:3",
"wallpaper": "壁纸16:9",
"media": "媒体3:4",
"poster": "海报9:16",
"square": "方形",
"landscape": "宽屏",
"portrait": "垂直",
"chatDialogue": "对话聊天",
"startNewConversationPrompt": "点击下方按钮,开始一个新的对话吧",
"newConversation": "新对话",
"networkModeEnabledContextInvalid": "已开启联网模式、上下文状态失效!",
"networkModeDisabled": "已关闭联网模式!",
"pointsMall": "积分商城",
"toggleTheme": "切换主题",
"signInReward": "签到奖励",
"networkMode": "联网模式",
"searchHistoryConversations": "搜索历史对话",
"announcement": "网站公告",
"clear": "清空对话",
"remaining": "剩余:",
"ordinaryPoints": "普通积分",
"advancedPoints": "高级积分",
"drawingPoints": "绘画积分",
"points": "积分",
"clearConversation": "清空对话",
"clearAllNonFavoriteConversations": "清空所有非收藏的对话?",
"more": "更多",
"collapse": "折叠",
"myApps": "我的应用",
"appSquare": "应用广场",
"favorites": "收藏",
"todayConversations": "今日对话",
"historyConversations": "历史对话",
"favoriteConversations": "收藏对话",
"unfavorite": "取消收藏",
"rename": "重命名",
"deleteConversation": "删除对话",
"me": "我",
"onlineSearch": "联网搜索",
"mindMap": "思维导图",
"fileAnalysis": "文件",
"delete": "删除",
"regenerate": "重新生成",
"pause": "暂停",
"loading": "加载中...",
"readAloud": "朗读",
"vipCenter": "会员中心",
"U1": "🔍 放大左上",
"U2": "🔍 放大右上",
"U3": "🔍 放大左下",
"U4": "🔍 放大右下",
"V1": "🪄 变换左上",
"V2": "🪄 变换右上",
"V3": "🪄 变换左下",
"V4": "🪄 变换右下",
"panLeft": "⬅️ 向左平移",
"panRight": "➡️ 向右平移",
"panUp": "⬆️ 向上平移",
"panDown": "⬇️ 向下平移",
"zoomIn15x": "↔️ 扩图1.5倍",
"zoomIn2x": "↔️ 扩图2倍",
"minorTransform": "🖌️ 微变换",
"strongTransform": "🖌️ 强变换",
"enlargeImagePrefix": "放大第",
"enlargeImageSuffix": "张图片",
"transformImagePrefix": "变换第",
"transformImageSuffix": "张图片",
"expandDrawing": "扩图绘制",
"advancedTransform": "高级变换",
"translateImage": "平移图片",
"imageToImage": "以图生图",
"faceConsistency": "人脸一致",
"styleConsistency": "风格一致",
"selectAppOrTopic": "选择应用或话题快速对话"
},
"app": {
"sampleTemplate": "示例模板",
"exploreInfinitePossibilities": "探索无限可能,与 AI 一同开创智慧未来",
"searchAppNameQuickFind": "搜索应用名称、快速查找应用...",
"allCategories": "全部分类",
"noModelConfigured": "管理员未配置特定应用模型、请联系管理员配置~"
},
"setting": {
"setting": "设置",
"general": "总览",
"advanced": "高级",
"personalInfo": "个人信息",
"avatarLink": "头像链接",
"name": "用户名称",
"sign": "用户签名",
"role": "角色设定",
"resetUserInfo": "重置用户信息",
"chatHistory": "聊天记录",
"theme": "主题",
"language": "语言",
"api": "API",
"reverseProxy": "反向代理",
"timeout": "超时",
"socks": "Socks",
"httpsProxy": "HTTPS Proxy",
"balance": "API余额"
},
"store": {
"siderButton": "提示词商店",
"local": "本地",
"online": "在线",
"title": "标题",
"description": "描述",
"clearStoreConfirm": "是否清空数据?",
"importPlaceholder": "请粘贴 JSON 数据到此处",
"addRepeatTitleTips": "标题重复,请重新输入",
"addRepeatContentTips": "内容重复:{msg},请重新输入",
"editRepeatTitleTips": "标题冲突,请重新修改",
"editRepeatContentTips": "内容冲突{msg} ,请重新修改",
"importError": "键值不匹配",
"importRepeatTitle": "标题重复跳过:{msg}",
"importRepeatContent": "内容重复跳过:{msg}",
"onlineImportWarning": "注意:请检查 JSON 文件来源!",
"downloadError": "请检查网络状态与 JSON 文件有效性"
},
"draw": {
"use": "使用",
"download": "下载",
"delete": "删除",
"zoom": "放大:",
"U1": "左上",
"U2": "右上",
"U3": "左下",
"U4": "右下",
"regenerateOnce": "重新生成一次",
"transform": "变换:",
"V1": "左上",
"V2": "右上",
"V3": "左下",
"V4": "右下",
"pan": "平移:",
"panLeft": "向左",
"panRight": "向右",
"panUp": "向上",
"panDown": "向下",
"transformZoom": "扩图变换:",
"zoom1_5x": "扩图1.5倍",
"zoom2x": "扩图2倍",
"minorTransform": "微变换",
"strongTransform": "强变换",
"regionalRedraw": "区域重绘",
"regionalRedraw1": "区域重绘(框选要改变的区域)",
"submitTask": "提交任务",
"selectSuiteForZoom": "操作:选中套图进行放大",
"selectSuiteForTransform": "操作:选中套图进行变换",
"regeneratingImage": "操作:正在对图片重新生成一次",
"drawingInProgress": "操作:正在火速绘制中...",
"tryDifferentPrompt": "执行:换个提示词重新试试吧!",
"statusWaiting": "等待中",
"statusDrawing": "绘制中",
"statusSuccess": "成功",
"statusFailure": "失败",
"statusTimeout": "超时",
"downloadImageTitle": "下载图片",
"downloadImageContent": "下载当前图片",
"downloadButtonText": "下载",
"cancelButtonText": "取消",
"deleteRecordTitle": "删除记录",
"deleteRecordContent": "删除当前绘制记录?",
"deleteButtonText": "删除",
"submitZoomDrawingSuccess": "提交放大绘制任务成功、请等待绘制结束!",
"submitRedrawSuccess": "提交重新绘制任务成功、请等待绘制结束!",
"submitTransformDrawingSuccess": "提交变换绘制任务成功、请等待绘制结束!",
"submitEnlargeDrawingSuccess": "提交扩图任务成功、请等待绘制结束!",
"submitAdvancedTransformDrawingSuccess": "提交高级变换绘制任务成功、请等待绘制结束!",
"submitRegionalRedrawSuccess": "提交区域重绘任务成功、请等待绘制结束!",
"drawingRecordDeleted": "绘制记录已删除!",
"queueing": "排队中...",
"drawing": "正在绘制...",
"storing": "图片存储中...",
"drawingFailed": "绘制失败",
"pointsRefunded": "积分已退还!",
"submitDrawingTaskSuccess": "提交绘制任务成功、请等待绘制结束!",
"defaultStyle": "默认风格",
"expressiveStyle": "表现力风格",
"cuteStyle": "可爱风格",
"scenicStyle": "景观风格",
"standardQuality": "普通",
"generalQuality": "一般",
"highDefinitionQuality": "高清",
"ultraHighDefinitionQuality": "超高清",
"enterDescription": "请输入描述词!",
"optimizationFailed": "优化失败了!",
"professionalDrawing": "专业绘图",
"parameterExplanation": "参数释义:生成图片尺寸比例",
"imageSize": "图片尺寸",
"modelSelection": "模型选择",
"tooltipMJ": "MJ: 偏真实通用模型",
"tooltipNIJI": "NIJI: 偏动漫风格、适用于二次元模型",
"version": "版本",
"style": "风格",
"parameters": "参数",
"parametersTooltip": "合理使用参数绘制更为理想的结果!",
"quality": "品质",
"chaos": "混乱",
"chaosDescription": "取值范围0-100、 --chaos 或 --c",
"chaosExplanation": "混乱级别可以理解为让AI天马行空的空间",
"chaosAdvice": "值越小越可靠、默认0最为精准",
"stylization": "风格化",
"stylizationDescription": "风格化:--stylize 或 --s范围 1-1000",
"parameterExplanation1": "参数释义:数值越高,画面表现也会更具丰富性和艺术性",
"setting": "设定",
"carryParameters": "携带参数",
"autoCarryParameters": "是否自动携带参数",
"carryOn": "打开:携带上述我们配置的参数",
"carryOff": "关闭:使用指令中的我们自定义的参数",
"imageToImage": "以图生图",
"clickOrDrag": "点击或拖拽一个图片到这里作为输入",
"supportFormats": "支持PNG和JPG格式",
"remainingPoints": "剩余积分",
"refresh": "刷新",
"accountInfo": "账户信息",
"points": "积分",
"paintingSingleUse": "绘画单次消耗:",
"imageGenerationSingleUse": "图生图单次消耗:",
"enlargementSingleUse": "放大单次消耗:",
"submitDrawingTask": "输入关键词,提交绘制任务",
"optimize": "优化",
"enterDrawingKeywords": "输入绘图关键词。例如:一只五颜六色的猫,可爱,卡通",
"unnecessaryElements": "不需要的元素",
"exclusionPrompt": "例生成房间图片、但是不要床、你可以填bed",
"workingContents": "工作中的内容",
"currentTasks": "当前系统进行中任务",
"goToAIDrawingSquare": "点击前往 AI 绘画广场",
"tasksInProgress": "个任务正在进行中、请耐心等候绘制完成、您可以前往其他页面稍后回来查看结果!",
"myDrawings": "我的绘图",
"aiDrawingSquare": "AI绘画广场",
"sizeAdjustment": "尺寸调整",
"keywordSearchPlaceholder": "prompt关键词搜索"
},
"pay": {
"membershipMarket": "会员商场",
"sizeAdjustment": "尺寸调整",
"memberPackage": "会员限时套餐",
"permanentAddOnCard": "叠加永久次卡",
"baseModelQuota": "普通积分",
"advancedModelQuota": "高级积分",
"MJDrawingQuota": "绘画积分",
"packageValidity": "套餐有效期",
"days": "天",
"permanent": "永久",
"points": "积分",
"welcomeTipMobile": "尽情探索,欢迎光临我们的在线商店!",
"welcomeTipDesktop": "尽情探索,欢迎光临我们的在线商店、感谢您选择我们、让我们一同开启愉悦的购物之旅!",
"paymentNotEnabled": "管理员还未开启支付!",
"purchaseSuccess": "购买成功、祝您使用愉快!",
"paymentNotComplete": "您还没有支付成功哟!",
"wechat": "微信",
"alipay": "支付宝",
"wechatPay": "微信支付",
"alipayPay": "支付宝支付",
"paymentSuccess": "恭喜你支付成功、祝您使用愉快!",
"paymentTimeout": "支付超时,请重新下单!",
"productPayment": "商品支付",
"amountDue": "需要支付:",
"packageName": "套餐名称:",
"packageDescription": "套餐描述:",
"siteAdminEnabledRedirect": "当前站长开通了跳转支付",
"clickToPay": "点击前往支付",
"completePaymentWithin": "请在",
"timeToCompletePayment": "时间内完成支付!",
"open": "打开",
"scanToPay": "扫码支付"
},
"mindmap": {
"title": "思维导图",
"yourNeeds": "您的需求?",
"inputPlaceholder": "请输入您想要生成内容的简单描述、AI将为您输出一份完整的markdown内容及其思维导图!",
"generateMindMapButton": "智能生成生成思维导图",
"contentRequirements": "内容需求",
"tryDemoButton": "试试示例",
"usageCredits": "每次使用消耗基础积分: 1",
"exportHTML": "导出HTML",
"exportPNG": "导出PNG",
"exportSVG": "导出SVG"
},
"usercenter": {
"defaultSignature": "我是一台基于深度学习和自然语言处理技术的 AI 机器人,旨在为用户提供高效、精准、个性化的智能服务。",
"syncComplete": "已同步数据完成",
"personalCenter": "个人中心",
"logOut": "退出登录",
"myUsageRecord": "我在本站的使用记录",
"basicModelCredits": "基础模型积分:",
"advancedModelCredits": "高级模型积分:",
"basicModelUsage": "基础模型使用:",
"advancedModelUsage": "高级模型使用:",
"drawingUsageCredits": "绘画使用积分:",
"bindWeChat": "绑定微信:",
"clickToBindWeChat": "点击绑定微信",
"weChatBound": "已绑定微信",
"syncVisitorData": "点击同步访客数据",
"points": "积分",
"membershipExpiration": "会员过期时间:",
"editInfoDescription": "编辑个人信息、查看更多详情",
"myDetails": "我的详情",
"myWallet": "我的钱包",
"basicInfo": "基础信息",
"userBasicSettings": "用户基础设置",
"avatarPlaceholder": "请填写头像地址",
"usernamePlaceholder": "请编辑您的用户名",
"signaturePlaceholder": "请编辑您的签名",
"passwordManagement": "密码管理",
"inviteBenefits": "邀请得福利",
"clickToLogin": "点击登入",
"notLoggedIn": "未登录",
"avatar": "头像",
"username": "用户名称",
"email": "用户邮箱",
"inviteeStatus": "受邀人状态",
"inviteTime": "邀请时间",
"rewardStatus": "获得奖励状态",
"certified": "已认证",
"notActivated": "未激活",
"rewardReceived": "已领取邀请奖励",
"waitingConfirmation": "等待受邀人确认",
"linkGeneratedSuccess": "生成邀请链接成功",
"generateLinkFirst": "请先生成您的专属邀请链接!",
"linkCopiedSuccess": "复制专属邀请链接成功!",
"copyNotSupported": "当前设置不支持自动复制、手动复制吧!",
"inviteForBenefits": "邀用户、得福利!",
"myInviteCode": "我的邀请码",
"generateInviteCode": "生成专属邀请码",
"copyInviteLink": "复制专属邀请链接",
"inviteOneUser": "邀请一位用户赠送",
"basicModelCredits1": "积分基础模型额度+",
"advancedModelCredits1": "积分高级模型额度+",
"mjDrawingCredits": "MJ绘画积分额度",
"receiveInvitation": "收到邀请用户获得",
"creditsEnd": "积分",
"invitationRecord": "邀请记录",
"passwordMinLength": "密码最短长度为6位数",
"passwordMaxLength": "密码最长长度为30位数",
"enterPassword": "请输入密码",
"reenterPassword": "请再次输入密码",
"passwordsNotMatch": "两次密码输入不一致",
"passwordUpdateSuccess": "密码更新成功、请重新登录系统!",
"changeYourPassword": "变更您的密码",
"oldPassword": "旧密码",
"newPassword": "新密码",
"confirmPassword": "确认密码",
"reloginAfterPasswordChange": "更新密码完成后将重新登录!",
"updateYourPassword": "更新您的密码",
"passwordRequirements": "密码要求",
"newPasswordInstructions": "要创建一个新的密码,你必须满足以下所有要求。",
"minimumCharacters": "最少6个字符",
"maximumCharacters": "最多30个字符",
"requireNumber": "至少带有一个数字",
"orderNumber": "订单编号",
"rechargeType": "充值类型",
"basicModelQuota": "普通积分",
"advancedModelQuota": "高级积分",
"mjDrawingQuota": "绘画积分",
"validity": "有效期",
"rechargeTime": "充值时间",
"enterCardSecret": "请先填写卡密!",
"cardRedeemSuccess": "卡密兑换成功、祝您使用愉快!",
"userWalletBalance": "用户钱包余额",
"basicModelBalance": "基础模型余额",
"creditUsageNote": "每次对话根据模型消费不同积分!",
"advancedModelBalance": "高级模型余额",
"modelConsumptionNote": "每次对话根据模型消费不同积分!",
"mjDrawingBalance": "MJ绘画余额",
"drawingConsumptionNote": "根据画图动作消耗不同的积分!",
"cardRecharge": "卡密充值",
"enterCardDetails": "请粘贴或填写您的卡密信息!",
"pleaseEnterCardDetails": "请输入卡密信息",
"exchange": "兑换",
"buyCardSecret": "购买卡密",
"rechargeRecords": "充值记录",
"packagePurchase": "套餐购买",
"buyPackage": "购买套餐"
},
"siderBar": {
"signInReward": "签到奖励",
"themeSwitch": "主题切换",
"personalCenter": "个人中心",
"loginAccount": "登录账户"
},
"notice": {
"doNotRemind24h": "我已知晓"
},
"login": {
"enterUsername": "请输入用户名",
"usernameLength": "用户名长度应为 2 到 30 个字符",
"enterPassword": "请输入密码",
"passwordLength": "密码长度应为 6 到 30 个字符",
"enterEmail": "请输入邮箱地址",
"enterPhone": "请输入手机号码",
"enterEmailOrPhone": "请输入邮箱地址或手机号码",
"emailValid": "请输入正确的邮箱地址",
"enterCaptcha": "请填写图形验证码",
"emailPhone": "邮箱 / 手机号",
"email": "邮箱",
"phone": "手机号",
"registrationSuccess": "账户注册成功、开始体验吧!",
"loginSuccess": "账户登录成功、开始体验吧!",
"registerTitle": "注册",
"enterContact": "请填写您的",
"enterCode": "请填写验证码",
"sendVerificationCode": "发送验证码",
"optionalInvitationCode": "邀请码[非必填]",
"registerAccount": "注册账户",
"alreadyHaveAccount": "已经有帐号?",
"goToLogin": "去登录",
"password": "密码",
"enterYourPassword": "请输入您的账户密码",
"rememberAccount": "记住帐号",
"forgotPassword": "忘记密码?",
"loginAccount": "登录账户",
"noAccount": "还没有帐号?",
"register": "去注册",
"orUse": "或使用",
"scanLogin": "扫码登录",
"wechatLogin": "微信登录",
"wechatScanFailed": "不使用微信扫码登录?试试",
"useWechatScan": "使用微信扫码登录"
},
"share": {
"orderAmount": "订单金额",
"productType": "商品类型",
"status": "状态",
"commissionRate": "佣金比例",
"commission": "佣金",
"orderTime": "订购时间",
"purchasePackage": "购买套餐",
"accounted": "已入账",
"generateInviteCodeSuccess": "生成邀请码成功",
"withdrawalTime": "提现时间",
"withdrawalAmount": "提现金额",
"withdrawalChannel": "提现渠道",
"withdrawalStatus": "提现状态",
"withdrawalRemarks": "提现备注",
"auditor": "审核人",
"alipay": "支付宝",
"wechat": "微信",
"paid": "已打款",
"rejected": "被拒绝",
"inReview": "审核中",
"avatar": "头像",
"username": "用户名",
"email": "邮箱",
"inviteeStatus": "受邀人状态",
"registered": "已注册",
"pendingActivation": "待激活",
"registrationTime": "注册时间",
"lastLogin": "最后登录",
"requestInviteCodeFirst": "请先申请你的邀请码",
"linkCopiedSuccess": "复制推荐链接成功",
"title": "推介计划",
"description": "加入我们,共享成功!欢迎来到我们的分销页面,成为我们的合作伙伴,一同开创美好未来!",
"defaultSalesOutletName": "新秀推荐官",
"myReferrals": "我的推介",
"currencyUnit": "元",
"remainingAmount": "剩余可提金额",
"withdrawingAmount": "提现中金额",
"withdrawNow": "立即提现",
"minimumWithdrawalPrefix": "最低",
"minimumWithdrawalSuffix": "元可提现",
"purchaseOrderCount": "购买订单数量",
"promotionLinkVisits": "推广链接访问次数",
"registeredUsers": "注册用户",
"referralEarnings": "推介收益",
"referralEarningsDescription": "推介的用户注册购买产品后返佣金额",
"percentage": "百分比",
"applyForAdvancedAgent": "申请成为高级代理",
"contactAdminForAdvancedAgent": "联系站长申请高级代理可享超高返佣",
"joinAsPartner": "加入我们成为合伙人",
"partnerDescription": "加入我们成为合伙人共同运营社区、合作双赢!",
"winTogether": "合作共赢,携手共进",
"referralLink": "推荐链接:",
"apply": "申请",
"referralRecordsTab": "推介记录",
"withdrawalRecordsTab": "提现记录",
"registeredUsersTab": "注册用户",
"inviteFriends": "邀好友、赠套餐卡密、享充值返佣!",
"inviteLink": "邀请链接",
"copy": "复制",
"inviteBenefits1": "邀请好友双方都可享受一定额度的永久次卡奖励",
"inviteBenefits2Prefix": "邀请好友充值,您可获得充值金额的",
"inviteBenefits2Suffix": "%返佣",
"enterWithdrawalAmount": "请填写你的提款金额!",
"selectWithdrawalChannel": "请选择你的提款渠道!",
"enterContactInfo": "请填写您的联系方式并备注!",
"optionalRemark": "如有特殊情况、请备注说明!",
"withdrawalSuccess": "申请提现成功、请耐心等待审核!",
"withdrawalApplicationForm": "提款申请表",
"contactInformation": "联系方式",
"withdrawalRemark": "提款备注",
"enterWithdrawalRemark": "请填写你的提款备注!",
"applyWithdrawal": "申 请 提 现"
},
"goods": {
"purchaseSuccess": "购买成功、祝您使用愉快!",
"paymentNotSuccessful": "您还没有支付成功哟!",
"orderConfirmationTitle": "订单确认",
"orderConfirmationContent": "欢迎选购、确定购买",
"thinkAgain": "我再想想",
"confirmPurchase": "确认购买",
"paymentNotEnabled": "管理员还未开启支付!",
"selectProducts": "选购套餐",
"basicModelQuota": "基础积分",
"advancedModelQuota": "高级积分",
"drawingQuota": "绘画积分",
"buyPackage": "购买套餐"
},
"rechargeTypes": {
"1": "注册赠送",
"2": "受邀请赠送",
"3": "邀请他人赠送",
"4": "购买卡密充值",
"5": "管理员赠送",
"6": "扫码购买充值",
"7": "MJ绘画失败退款",
"8": "签到奖励"
},
"orderStatus": {
"0": "未支付",
"1": "已支付",
"2": "支付失败",
"3": "支付超时"
},
"messages": {
"logoutSuccess": "登出账户成功!"
}
}

103
chat/src/locales/zh-TW.json Normal file
View File

@@ -0,0 +1,103 @@
{
"common": {
"add": "新增",
"addSuccess": "新增成功",
"edit": "編輯",
"editSuccess": "編輯成功",
"delete": "刪除",
"deleteSuccess": "刪除成功",
"save": "儲存",
"saveSuccess": "儲存成功",
"reset": "重設",
"action": "操作",
"export": "匯出",
"exportSuccess": "匯出成功",
"import": "匯入",
"importSuccess": "匯入成功",
"clear": "清除",
"clearSuccess": "清除成功",
"yes": "是",
"no": "否",
"confirm": "確認",
"download": "下載",
"noData": "目前無資料",
"wrong": "發生錯誤,請稍後再試。",
"success": "操作成功",
"failed": "操作失敗",
"verify": "驗證",
"unauthorizedTips": "未經授權,請先進行驗證。"
},
"chat": {
"newChatButton": "新建對話",
"placeholder": "來說點什麼...Shift + Enter = 換行)",
"placeholderMobile": "來說點什麼...",
"copy": "複製",
"copied": "複製成功",
"copyCode": "複製代碼",
"clearChat": "清除對話",
"clearChatConfirm": "是否清空對話?",
"exportImage": "儲存對話為圖片",
"exportImageConfirm": "是否將對話儲存為圖片?",
"exportSuccess": "儲存成功",
"exportFailed": "儲存失敗",
"usingContext": "上下文模式",
"turnOnContext": "啟用上下文模式,在此模式下,發送訊息會包含之前的聊天記錄。",
"turnOffContext": "關閉上下文模式,在此模式下,發送訊息不會包含之前的聊天記錄。",
"deleteMessage": "刪除訊息",
"deleteMessageConfirm": "是否刪除此訊息?",
"deleteHistoryConfirm": "確定刪除此紀錄?",
"clearHistoryConfirm": "確定清除紀錄?",
"preview": "預覽",
"showRawText": "顯示原文",
"loading": "加載中...",
"readAloud": "朗讀",
"vipCenter": "會員中心"
},
"setting": {
"setting": "設定",
"general": "總覽",
"advanced": "高級",
"config": "設定",
"avatarLink": "頭貼連結",
"name": "名稱",
"description": "描述",
"role": "角色設定",
"resetUserInfo": "重設使用者資訊",
"chatHistory": "紀錄",
"theme": "主題",
"language": "語言",
"api": "API",
"reverseProxy": "反向代理",
"timeout": "逾時",
"socks": "Socks",
"httpsProxy": "HTTPS Proxy",
"balance": "API余額"
},
"store": {
"siderButton": "提示詞商店",
"local": "本機",
"online": "線上",
"title": "標題",
"description": "描述",
"clearStoreConfirm": "是否清除資料?",
"importPlaceholder": "請將 JSON 資料貼在此處",
"addRepeatTitleTips": "標題重複,請重新輸入",
"addRepeatContentTips": "內容重複:{msg},請重新輸入",
"editRepeatTitleTips": "標題衝突,請重新修改",
"editRepeatContentTips": "內容衝突{msg} ,請重新修改",
"importError": "鍵值不符合",
"importRepeatTitle": "因標題重複跳過:{msg}",
"importRepeatContent": "因內容重複跳過:{msg}",
"onlineImportWarning": "注意:請檢查 JSON 檔案來源!",
"downloadError": "請檢查網路狀態與 JSON 檔案有效性"
},
"usercenter": {
"enterCardSecret": "請先填寫卡密!",
"cardRedeemSuccess": "卡密兌換成功、祝您使用愉快!",
"cardRecharge": "卡密充值",
"enterCardDetails": "請粘貼或填寫您的卡密資訊!",
"pleaseEnterCardDetails": "請輸入卡密資訊",
"exchange": "兌換",
"buyCardSecret": "購買卡密"
}
}

84
chat/src/main.ts Normal file
View File

@@ -0,0 +1,84 @@
import { useTheme } from '@/hooks/useTheme'
import { useAuthStoreWithout } from '@/store/modules/auth'
import '@/styles/github-markdown.less'
import '@/styles/global.less'
// import '@/styles/highlight.less' // 移除旧的highlight样式
import '@/styles/index.css'
import { print99aiInfo, printAppInfo } from '@/utils/logger'
import { message } from '@/utils/message'
import router from '@/utils/router'
import 'katex/dist/katex.min.css'
import { createApp } from 'vue'
import App from './App.vue'
import { setupI18n } from './locales'
import { setupImageViewer } from './plugins/imageViewer'
import { setupStore } from './store'
// 禁用生产环境中的 console.log
if (process.env.NODE_ENV === 'production') {
console.log = () => {}
console.warn = () => {}
console.error = () => {}
}
// 检测系统主题并设置应用主题
function detectSystemTheme() {
const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)')
const storedTheme = localStorage.getItem('theme')
const theme = storedTheme || (prefersDarkScheme.matches ? 'dark' : 'light')
localStorage.setItem('theme', theme)
document.documentElement.classList.toggle('dark', theme === 'dark')
// 设置 data-theme 方式的主题系统
document.documentElement.dataset.theme = theme
}
const authStore = useAuthStoreWithout()
async function bootstrap() {
const app = createApp(App)
// 设置样式和资源
setupStore(app)
setupI18n(app)
setupImageViewer(app)
// 安装Vue Router
app.use(router)
// 检测系统主题并设置应用主题
detectSystemTheme()
// 初始化主题
const { init } = useTheme()
init()
// 初始化消息组件
const msgInstance = message()
// 在开发环境下打印控制台信息
print99aiInfo()
printAppInfo('99AI', '5.0.1')
const domain = `${window.location.protocol}//${window.location.hostname}${
window.location.port ? `:${window.location.port}` : ''
}`
await authStore.getGlobalConfig(domain)
// 延迟加载插件
const VueViewer = (await import('v-viewer')).default
app.use(VueViewer)
const { MotionPlugin } = await import('@vueuse/motion')
app.use(MotionPlugin)
// 在卸载应用前清理资源
app.config.globalProperties.$onAppUnmount = () => {
if (msgInstance && typeof msgInstance.destroy === 'function') {
msgInstance.destroy()
}
}
app.mount('#app')
}
bootstrap()

View File

@@ -0,0 +1,11 @@
import GlobalImageViewer from '@/components/common/ImageViewer/GlobalImageViewer.vue'
import ImageViewerPlugin from '@/components/common/ImageViewer/useImageViewer'
import { App } from 'vue'
export function setupImageViewer(app: App) {
// 安装图片预览器插件
app.use(ImageViewerPlugin)
// 注册全局图片预览器组件
app.component('GlobalImageViewer', GlobalImageViewer)
}

View File

@@ -0,0 +1,80 @@
import { fetchLoginByCodeAPI, fetchWxLoginRedirectAPI } from '@/api/user'
import { useAuthStore } from '@/store'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
// 全局作用域不使用useRouter()
const authStore = useAuthStore()
const isLogin = computed(() => authStore.isLogin)
// 微信登录逻辑
export async function loginByWechat() {
if (isLogin.value) {
return
}
// 在函数内部使用useRouter
const router = useRouter()
const authStore = useAuthStore()
const urlParams = new URLSearchParams(window.location.search)
// alert(urlParams);
const codes = urlParams.getAll('code')
const code = codes.length > 0 ? codes[codes.length - 1] : null
if (code) {
try {
const res = await fetchLoginByCodeAPI<any>({ code: code })
if (res.success) {
authStore.setToken(res.data)
await authStore.getUserInfo()
authStore.setLoginDialog(false) // 关闭登录对话框
// return { success: true };
router.replace('/')
}
} catch (error) {
console.error('微信登录失败', error)
}
} else {
try {
const currentUrl = window.location.href
// const currentUrl = window.location.href.split('#')[0];
const res = await fetchWxLoginRedirectAPI<any>({ url: currentUrl })
// alert(res.data);
if (res.success) {
// alert(window.location.href);
window.location.replace(res.data)
// window.location.href = res.data; // 跳转到微信授权页面
}
} catch (error) {
console.error('获取微信授权链接失败', error)
}
}
return { success: false }
}
// /**
// * 检测当前浏览器是否是微信内置浏览器
// * 区分微信和企业微信只有微信浏览器返回true
// * @returns {boolean} 如果是微信浏览器返回 true否则返回 false
// */
// export function isWechatBrowser(): boolean {
// const ua = navigator.userAgent.toLowerCase();
// // 检查是否是企业微信
// const isWXWork = ua.indexOf('wxwork') !== -1;
// // 检查是否是微信,排除企业微信的情况
// const isWeixin = !isWXWork && ua.indexOf('micromessenger') !== -1;
// return isWeixin;
// }
// 初始化微信登录
export function initWechatLogin() {
// if (isWechatBrowser()) {
loginByWechat() // 执行微信登录逻辑
// }
}

10
chat/src/store/index.ts Normal file
View File

@@ -0,0 +1,10 @@
import { createPinia } from 'pinia'
import type { App } from 'vue'
export const store = createPinia()
export function setupStore(app: App) {
app.use(store)
}
export * from './modules'

View File

@@ -0,0 +1,44 @@
import { ss } from '@/utils/storage'
function detectEnvironment() {
if (typeof process !== 'undefined' && process?.type === 'renderer') return 'electron'
else if (typeof wx !== 'undefined') return 'wechat'
else if (typeof window !== 'undefined' && window.matchMedia('(display-mode: standalone)').matches)
return 'webApp'
else if (/(Android|webOS|iPhone|iPad|iPod|BlackBerry|Windows Phone)/i.test(navigator.userAgent))
return 'mobile'
else return 'webBrowser'
}
const LOCAL_NAME = 'appSetting'
export type Theme = 'light' | 'dark' | 'auto'
export type Language = 'zh-CN' | 'zh-TW' | 'en-US'
export type Env = 'electron' | 'wechat' | 'web' | 'mobile'
export interface AppState {
siderCollapsed: boolean
theme: Theme
language: Language
env: Env
}
export function defaultSetting(): AppState {
return {
siderCollapsed: false,
theme: 'light',
language: 'zh-CN',
env: detectEnvironment(),
}
}
export function getLocalSetting(): AppState {
const localSetting: AppState | undefined = ss.get(LOCAL_NAME)
return { ...defaultSetting(), ...localSetting }
}
export function setLocalSetting(setting: AppState): void {
ss.set(LOCAL_NAME, setting)
}

View File

@@ -0,0 +1,59 @@
import { store } from '@/store'
import { defineStore } from 'pinia'
import type { AppState, Language, Theme } from './helper'
import { getLocalSetting, setLocalSetting } from './helper'
export const useAppStore = defineStore('app-store', {
state: (): AppState => getLocalSetting(),
actions: {
setSiderCollapsed(collapsed: boolean) {
this.siderCollapsed = collapsed
this.recordState()
},
setTheme(theme: Theme) {
localStorage.theme = theme
this.theme = theme
window.theme = theme
this.recordState()
// 切换暗黑模式逻辑
if (theme === 'dark') {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
},
setLanguage(language: Language) {
if (this.language !== language) {
this.language = language
this.recordState()
}
},
recordState() {
setLocalSetting(this.$state)
},
setEnv() {
const isWeChat = /micromessenger/i.test(navigator.userAgent)
const isElectron = navigator.userAgent.includes('Electron')
const isMobile = /(iPhone|iPad|iPod|Android|webOS|BlackBerry|Windows Phone)/i.test(
navigator.userAgent
)
const isWeb = !isWeChat && !isElectron
if (isWeChat) this.env = 'wechat'
else if (isElectron) this.env = 'electron'
else if (isMobile) this.env = 'mobile'
else if (isWeb) this.env = 'web'
},
},
})
export function useAppStoreWithOut() {
return useAppStore(store)
}

View File

@@ -0,0 +1,21 @@
export interface MineApp {
userId: number
appId: number
public: boolean
status: number
demoData: string
order: number
appDes: string
preset: string
appRole: string
coverImg: string
appName: string
loading?: boolean
backgroundImg?: string
prompt?: string
}
export interface AppStoreState {
catId: number
mineApps: MineApp[]
}

View File

@@ -0,0 +1,26 @@
import { fetchQueryMineAppsAPI } from '@/api/appStore'
import { store } from '@/store'
import { defineStore } from 'pinia'
import type { AppStoreState } from './helper'
export const useAppCatStore = defineStore('app-cat-store', {
state: (): AppStoreState => ({
catId: 0,
mineApps: [],
}),
actions: {
setCatId(catId: number) {
this.catId = catId
},
async queryMineApps() {
const res = await fetchQueryMineAppsAPI()
this.mineApps = res?.data?.rows || []
},
},
})
export function useAppCatStoreWithOut() {
return useAppCatStore(store)
}

View File

@@ -0,0 +1,140 @@
import { ss } from '@/utils/storage'
const LOCAL_NAME = 'SECRET_TOKEN'
export function getToken() {
return ss.get(LOCAL_NAME)
}
export function setToken(token: string) {
return ss.set(LOCAL_NAME, token)
}
export function removeToken() {
return ss.remove(LOCAL_NAME)
}
export interface UserBalance {
isMember: boolean
model3Count: number
model4Count: number
drawMjCount: number
memberModel3Count: number
memberModel4Count: number
memberDrawMjCount: number
useModel3Count: number
useModel4Count: number
useModel3Token: number
useModel4Token: number
useDrawMjToken: number
sumModel3Count: number
sumModel4Count: number
sumDrawMjCount: number
expirationTime: Date
}
export interface GlobalConfig {
siteName: string
siteUrl: string
qqNumber: string
vxNumber: string
baiduCode: string
buyCramiAddress: string
noticeInfo: string
registerSendStatus: string
registerSendModel3Count: string
registerSendModel4Count: string
registerSendDrawMjCount: string
clientHomePath: string
clientLogoPath: string
enableHtmlRender: string
clientFaviconPath: string
isUseWxLogin: boolean
robotAvatar: string
siteRobotName: string
mindDefaultData: string
payEpayStatus: string
payDuluPayStatus: string
payHupiStatus: string
payWechatStatus: string
payEpayChannel: string
payDuluPayChannel: string
payDuluPayRedirect: string
payHupiChannel: string
payWechatChannel: string
payEpayApiPayUrl: string
payMpayStatus: string
payMpayChannel: string
isAutoOpenNotice: string
isShowAppCatIcon: string
salesBaseRatio: string
salesSeniorRatio: string
salesAllowDrawMoney: string
companyName: string
filingNumber: string
emailLoginStatus: string
phoneLoginStatus: string
openIdentity: string
openPhoneValidation: string
wechatRegisterStatus: string
wechatSilentLoginStatus: string
oldWechatMigrationStatus: string
officialOldAccountSuccessText: string
officialOldAccountFailText: string
signInStatus: string
signInModel3Count: string
signInModel4Count: string
signInMjDrawToken: string
appMenuHeaderTips: string
appMenuHeaderBgUrl: string
pluginFirst: string
mjHideNotBlock: string
mjUseBaiduFy: string
mjHideWorkIn: string
isVerifyEmail: string
payLtzfStatus: string
drawingStyles: string
isHidePlugin: string
showWatermark: string
isHideTts: string
isHideDefaultPreset: string
isHideModel3Point: string
isHideModel4Point: string
isHideDrawMjPoint: string
model3Name: string
model4Name: string
drawMjName: string
isModelInherited: string
noVerifyRegister: string
homeHtml: string
isAutoOpenAgreement: string
agreementInfo: string
agreementTitle: string
isEnableExternalLinks: string
externalLinks: string
clearCacheEnabled: string
noticeTitle: string
streamCacheEnabled: string
homeWelcomeContent: string
sideDrawingEditModel: string
}
export interface AuthState {
token: string | undefined
loginDialog: boolean
globalConfigLoading: boolean
loadInit: boolean
userInfo: {
username: string
email: string
role: string
id: number
avatar?: string
sign?: string
isBindWx: boolean
consecutiveDays: number
nickname: string
}
userBalance: UserBalance
globalConfig: GlobalConfig
}

View File

@@ -0,0 +1,98 @@
import { defineStore } from 'pinia'
import { useChatStore } from '../chat'
import { fetchGetInfo } from '@/api'
import { fetchGetBalanceQueryAPI } from '@/api/balance'
import { fetchQueryConfigAPI } from '@/api/config'
import type { ResData } from '@/api/types'
import { store } from '@/store'
import type { AuthState, GlobalConfig, UserBalance } from './helper'
import { getToken, removeToken, setToken } from './helper'
export const useAuthStore = defineStore('auth-store', {
state: (): AuthState => ({
token: getToken(),
loginDialog: false,
globalConfigLoading: true,
userInfo: {},
userBalance: {},
globalConfig: {} as GlobalConfig,
loadInit: false,
}),
getters: {
isLogin: (state: AuthState) => !!state.token,
},
actions: {
async getUserInfo(): Promise<T> {
try {
if (!this.loadInit) await this.getGlobalConfig()
const res = await fetchGetInfo()
if (!res) return Promise.resolve(res)
const { data } = res
const { userInfo, userBalance } = data
this.userInfo = { ...userInfo }
this.userBalance = { ...userBalance }
return Promise.resolve(data)
} catch (error) {
return Promise.reject(error)
}
},
updateUserBalance(userBalance: UserBalance) {
this.userBalance = userBalance
},
async getUserBalance() {
const res: ResData = await fetchGetBalanceQueryAPI()
const { success, data } = res
if (success) this.userBalance = data
},
async getGlobalConfig(domain = '') {
const res = await fetchQueryConfigAPI({ domain })
this.globalConfig = res.data as GlobalConfig
this.globalConfigLoading = false
this.loadInit = true
},
setToken(token: string) {
this.token = token
setToken(token)
},
removeToken() {
this.token = undefined
removeToken()
},
setLoginDialog(bool: boolean) {
this.loginDialog = bool
},
logOut() {
this.token = undefined
removeToken()
this.userInfo = {}
this.userBalance = {}
// message().success('登出账户成功!')
const chatStore = useChatStore()
chatStore.clearChat()
window.location.reload()
},
updatePasswordSuccess() {
this.token = undefined
removeToken()
this.userInfo = {}
this.userBalance = {}
this.loginDialog = true
},
},
})
export function useAuthStoreWithout() {
return useAuthStore(store)
}

View File

@@ -0,0 +1,46 @@
import { ss } from '@/utils/storage'
const LOCAL_NAME = 'chatStorage'
export function defaultState(): Chat.ChatState {
return {
active: 0,
usingContext: true,
usingNetwork: false,
usingDeepThinking: false,
usingMcpTool: false,
groupList: [],
chatList: [],
groupKeyWord: '',
baseConfig: null,
currentPlugin: undefined,
pluginList: [],
prompt: '',
reasoningText: '',
}
}
export function getLocalState(): Chat.ChatState {
const localState = ss.get(LOCAL_NAME)
return { ...defaultState(), ...localState }
}
export function setLocalState({ active }: Chat.ChatState) {
ss.set(LOCAL_NAME, { ...ss.get(LOCAL_NAME), active })
}
export function formatChatPre(data: any): any {
return data.map((item: any) => {
const { name, childList, id } = item
return {
label: name,
value: id,
children: childList.map((t: any) => {
return {
label: t.title,
value: t.prompt,
}
}),
}
})
}

View File

@@ -0,0 +1,396 @@
import {
fetchCreateGroupAPI,
fetchDelAllGroupAPI,
fetchDelGroupAPI,
fetchQueryGroupAPI,
fetchUpdateGroupAPI,
} from '@/api/group'
import { defineStore } from 'pinia'
import { getLocalState, setLocalState } from './helper'
import {
fetchDelChatLogAPI,
fetchDelChatLogByGroupIdAPI,
fetchDeleteGroupChatsAfterIdAPI,
fetchQueryChatLogListAPI,
} from '@/api/chatLog'
import { fetchModelBaseConfigAPI } from '@/api/models'
import { fetchQueryPluginsAPI } from '@/api/plugin'
import { useGlobalStoreWithOut } from '@/store'
const useGlobalStore = useGlobalStoreWithOut()
export const useChatStore = defineStore('chat-store', {
state: (): Chat.ChatState => getLocalState(),
getters: {
/* 当前选用模型的配置 */
activeConfig: state => {
const uuid = state.active
if (!uuid) return {}
const config = state.groupList.find(item => item.uuid === uuid)?.config
const parsedConfig = config ? JSON.parse(config) : state.baseConfig
return parsedConfig
},
activeGroupAppId: state => {
const uuid = state.active
if (!uuid) return null
return state.groupList.find(item => item.uuid === uuid)?.appId
},
activeGroupFileUrl: state => {
const uuid = state.active
if (!uuid) return null
return state.groupList.find(item => item.uuid === uuid)?.fileUrl
},
/* 当前选用模型的名称 */
activeModel(state) {
return this.activeConfig?.modelInfo?.model
},
/* 当前选用模型的名称 */
activeModelName(state) {
return this.activeConfig?.modelInfo?.modelName
},
/* 当前选用模型的名称 */
activeModelAvatar(state) {
return this.activeConfig?.modelInfo?.modelAvatar
},
/* 当前选用模型的扣费类型 */
activeModelDeductType(state) {
return this.activeConfig?.modelInfo?.deductType
},
/* 当前选用模型的模型类型 */
activeModelKeyType(state) {
return this.activeConfig?.modelInfo?.keyType
},
/* 当前选用模型支持上传文件的格式 */
activeModelFileUpload(state) {
return this.activeConfig?.modelInfo?.isFileUpload
},
/* 当前选用模型的调用价格 */
activeModelPrice(state) {
return this.activeConfig?.modelInfo?.deduct
},
},
actions: {
/* 查询插件列表 */
async queryPlugins() {
try {
const res: any = await fetchQueryPluginsAPI()
if (res.success && res.code === 200) {
// 过滤掉不启用的插件并只保留需要的字段
this.pluginList = res.data.rows
.filter((plugin: any) => plugin.isEnabled === 1)
.map((plugin: any) => ({
pluginId: plugin.id,
pluginName: plugin.name,
description: plugin.description,
pluginImg: plugin.pluginImg,
parameters: plugin.parameters,
deductType: plugin.deductType,
drawingType: plugin.drawingType,
modelType: plugin.modelType,
}))
} else {
}
} catch (error) {}
},
/* 对话组过滤 */
setGroupKeyWord(keyWord: string) {
this.groupKeyWord = keyWord
},
/* 计算拿到当前选择的对话组信息 */
getChatByGroupInfo() {
if (this.active) return this.groupList.find(item => item.uuid === this.active)
},
/* */
getConfigFromUuid(uuid: any) {
return this.groupList.find(item => item.uuid === uuid)?.config
},
/* 新增新的对话组 */
async addNewChatGroup(appId = 0, modelConfig?: any, params?: string) {
try {
const res: any = await fetchCreateGroupAPI({
appId,
modelConfig,
params,
})
this.active = res.data.id
this.usingNetwork = false
this.usingDeepThinking = false
this.usingMcpTool = false
this.recordState()
await this.queryMyGroup()
await this.setActiveGroup(res.data.id)
} catch (error) {}
},
/* 查询基础模型配置 */
async getBaseModelConfig() {
const res = await fetchModelBaseConfigAPI()
this.baseConfig = res?.data
},
/* 查询我的对话组 */
async queryMyGroup() {
const res: any = await fetchQueryGroupAPI()
this.groupList = [
...res.data.map((item: any) => {
const {
id: uuid,
title,
isSticky,
createdAt,
updatedAt,
appId,
config,
appLogo,
isFixedModel,
isGpts,
params,
fileUrl,
content,
appModel,
} = item
return {
uuid,
title,
isEdit: false,
appId,
config,
isSticky,
appLogo,
createdAt,
isFixedModel,
isGpts,
params,
fileUrl,
content,
appModel,
updatedAt: new Date(updatedAt).getTime(),
}
}),
]
const isHasActive = this.groupList.some(
(item: { uuid: any }) => Number(item.uuid) === Number(this.active)
)
if (!this.active || !isHasActive) {
this.groupList.length && this.setActiveGroup(this.groupList[0].uuid)
}
// 如果 groupList 为空,新建一个对话组
if (this.groupList.length === 0) {
await this.addNewChatGroup()
}
this.recordState()
},
/* 修改对话组信息 */
async updateGroupInfo(params: {
groupId: number
title?: string
isSticky?: boolean
fileUrl?: string
}) {
await fetchUpdateGroupAPI(params)
await this.queryMyGroup()
},
/* 变更对话组 */
// 设置当前激活的对话组
async setActiveGroup(uuid: number) {
useGlobalStore.updateShowAppListComponent(false)
// useGlobalStore.updateImagePreviewer(false)
// this.chatList = [];
this.active = uuid
this.groupList.forEach(item => (item.isEdit = false))
await this.queryActiveChatLogList()
if (this.active) {
await this.queryActiveChatLogList()
} else {
this.chatList = []
}
this.active = uuid
// 记录当前状态
this.recordState()
},
/* 删除对话组 */
async deleteGroup(params: Chat.History) {
const curIndex = this.groupList.findIndex(item => item.uuid === params.uuid)
const { uuid: groupId } = params
await fetchDelGroupAPI({ groupId })
await this.queryMyGroup()
if (this.groupList.length === 0) await this.setActiveGroup(0)
if (curIndex > 0 && curIndex < this.groupList.length)
await this.setActiveGroup(this.groupList[curIndex].uuid)
if (curIndex === 0 && this.groupList.length > 0)
await this.setActiveGroup(this.groupList[0].uuid)
if (curIndex > this.groupList.length || (curIndex === 0 && this.groupList.length === 0))
await this.setActiveGroup(0)
if (curIndex > 0 && curIndex === this.groupList.length)
await this.setActiveGroup(this.groupList[curIndex - 1].uuid)
this.recordState()
},
/* 删除全部非置顶对话组 */
async delAllGroup() {
if (!this.active || !this.groupList.length) return
await fetchDelAllGroupAPI()
await this.queryMyGroup()
if (this.groupList.length === 0) await this.setActiveGroup(0)
else await this.setActiveGroup(this.groupList[0].uuid)
},
// /* 查询当前对话组的聊天记录 */
// async queryActiveChatLogList() {
// if (!this.active || Number(this.active) === 0) return;
// const res: any = await fetchQueryChatLogListAPI({ groupId: this.active });
// this.chatList = res.data;
// this.recordState();
// },
/* 查询当前对话组的聊天记录 */
/* 查询当前对话组的聊天记录 */
async queryActiveChatLogList() {
// 如果没有激活的对话组,或者 groupId 为 0则不进行查询
if (!this.active || Number(this.active) === 0) {
this.chatList = [] // 确保没有数据时清空 chatList
return
}
try {
// 调用 API 查询聊天记录
const res: any = await fetchQueryChatLogListAPI({
groupId: this.active,
})
// 检查响应数据并更新 chatList
if (res && res.data) {
this.chatList = res.data
} else {
this.chatList = [] // 如果没有数据,确保 chatList 为空数组
}
} catch (error) {
// 捕获错误并处理
this.chatList = [] // 出错时清空 chatList
} finally {
// 无论成功还是失败,都调用 recordState
this.recordState()
}
},
/* 添加一条虚拟的对话记录 */
addGroupChat(data: Chat.Chat) {
this.chatList = [...this.chatList, data]
},
/* 动态修改对话记录 */
updateGroupChat(index: number, data: Chat.Chat) {
this.chatList[index] = { ...this.chatList[index], ...data }
},
/* 修改其中部分内容 */
updateGroupChatSome(index: number, data: Partial<Chat.Chat>) {
this.chatList[index] = { ...this.chatList[index], ...data }
},
/* 删除一条对话记录 */
async deleteChatById(chatId: number | undefined) {
if (!chatId) return
await fetchDelChatLogAPI({ id: chatId })
await this.queryActiveChatLogList()
},
/* 删除一条对话记录 */
async deleteChatsAfterId(chatId: number | undefined) {
if (!chatId) return
await fetchDeleteGroupChatsAfterIdAPI({ id: chatId })
await this.queryActiveChatLogList()
},
/* 设置使用上下文 */
setUsingContext(context: boolean) {
this.usingContext = context
this.recordState()
},
/* 设置使用联网 */
setUsingNetwork(context: boolean) {
this.usingNetwork = context
this.recordState()
},
/* 设置使用深度思考 */
setUsingDeepThinking(context: boolean) {
this.usingDeepThinking = context
this.recordState()
},
/* 设置使用 MCP 工具 */
setUsingMcpTool(context: boolean) {
this.usingMcpTool = context
this.recordState()
},
setUsingPlugin(plugin: any) {
// Set the current plugin to the new plugin if provided, else clear it
this.currentPlugin = plugin || undefined
this.recordState() // Record the state change
},
async setPrompt(prompt: string) {
this.prompt = prompt
this.recordState()
},
setStreamIn(isStreamIn: boolean) {
this.isStreamIn = isStreamIn
this.recordState()
},
/* 删除当前对话组的全部内容 */
async clearChatByGroupId() {
if (!this.active) return
await fetchDelChatLogByGroupIdAPI({ groupId: this.active })
await this.queryActiveChatLogList()
},
recordState() {
setLocalState(this.$state)
},
clearChat() {
this.chatList = []
this.groupList = []
this.active = 0
this.recordState()
},
},
})

View File

@@ -0,0 +1,88 @@
import { ss } from '@/utils/storage'
import { UserState } from '../users/helper'
const LOCAL_NAME = 'userStorage'
export interface UserInfo {
avatar: string
name: string
}
export interface OrderInfo {
pkgInfo: {
id: number
des: string
name: string
price: string
model3Count: number
model4Count: number
drawMjCount: number
coverImg: string
days: number
}
}
export interface GlobalState {
loading: boolean
showLoginDialog: boolean
goodsDialog: boolean
fingerprint: number
noticeDialog: boolean
bindWxDialog: boolean
signInDialog: boolean
appDialog: boolean
identityDialog: boolean
phoneIdentityDialog: boolean
userAgreementDialog: boolean
BadWordsDialog: boolean
htmlDialog: boolean
pythonDialog: boolean
pythonContent: string
settingsDialog: boolean
mobileSettingsDialog: boolean
settingsActiveTab: number
mobileInitialTab?: string
isChatIn: boolean
isCacheEnabled: boolean
orderInfo: OrderInfo
model: number
iframeUrl: string
clipboardText: string
htmlContent: string
contentType: 'html' | 'mermaid' | 'markmap' | ''
textContent: string
full_json: string
externalLinkDialog: boolean
showAppListComponent: boolean
showBadWordsDialog: boolean
showHtmlPreviewer: boolean
showTextEditor: boolean
showImagePreviewer: boolean
showWorkflowPreviewer: boolean
showMarkdownPreviewer: boolean
isMarkdownPreviewerVisible: boolean
previewImageUrls: string[]
initialImageIndex: number
currentExternalLink: string | null
mjImageData: any
workflowContent: string[]
markdownContent: string
}
export function defaultSetting(): UserState {
return {
userInfo: {
avatar: '',
name: '未登录',
},
}
}
export function getLocalState(): UserState {
const localSetting: UserState | undefined = ss.get(LOCAL_NAME)
return { ...defaultSetting(), ...localSetting }
}
export function setLocalState(setting: UserState): void {
ss.set(LOCAL_NAME, setting)
}

View File

@@ -0,0 +1,291 @@
import { store } from '@/store'
import { ss } from '@/utils/storage'
import { defineStore } from 'pinia'
import { useChatStore } from '../chat'
import type { GlobalState } from './helper'
// 定义对话框的索引常量,方便后续使用
export const DIALOG_TABS = {
ACCOUNT: 0, // 账户管理
MEMBER: 1, // 会员中心
NOTICE: 2, // 网站公告
AGREEMENT: 3, // 用户协议
}
export const useGlobalStore = defineStore('global-store', {
state: (): GlobalState => ({
loading: false,
showAppListComponent: false,
settingsDialog: false,
showLoginDialog: false,
showBadWordsDialog: false,
showHtmlPreviewer: false,
showTextEditor: false,
showImagePreviewer: false,
showWorkflowPreviewer: false,
showMarkdownPreviewer: false,
previewImageUrls: [],
initialImageIndex: 0,
pythonDialog: false,
htmlDialog: false,
isChatIn: false,
settingsActiveTab: 0,
htmlContent: '',
contentType: '',
textContent: '',
pythonContent: '',
full_json: '',
externalLinkDialog: false,
currentExternalLink: null,
mobileSettingsDialog: false,
goodsDialog: false,
fingerprint: 0,
noticeDialog: false,
bindWxDialog: false,
signInDialog: false,
appDialog: false,
identityDialog: false,
phoneIdentityDialog: false,
userAgreementDialog: false,
BadWordsDialog: false,
isCacheEnabled: false,
orderInfo: {
pkgInfo: {
id: 0,
des: '',
name: '',
price: '',
model3Count: 0,
model4Count: 0,
drawMjCount: 0,
coverImg: '',
days: 0,
},
},
model: 0,
iframeUrl: '',
clipboardText: '',
mjImageData: {},
mobileInitialTab: undefined,
workflowContent: [],
markdownContent: '',
isMarkdownPreviewerVisible: false,
}),
actions: {
updateClipboardText(text: string) {
this.clipboardText = text
},
updateTextContent(text: string) {
this.textContent = text
},
updateFullJson(json: string) {
this.full_json = json
},
updateFingerprint(str: number) {
let id = str
/* 超过mysql最大值进行截取 */
if (id > 2147483647) {
id = Number(id.toString().slice(-9))
id = Number(String(Number(id)))
}
ss.set('fingerprint', id)
this.fingerprint = id
},
updateIframeUrl(iframeUrl: string) {
this.iframeUrl = iframeUrl
},
updateUserAgreementDialog(userAgreementDialog: boolean) {
this.userAgreementDialog = userAgreementDialog
},
UpdateBadWordsDialog(BadWordsDialog: boolean) {
this.BadWordsDialog = BadWordsDialog
},
updateHtmlContent(
htmlContent: string,
contentType: 'html' | 'mermaid' | 'markmap' | '' = 'html'
) {
this.htmlContent = htmlContent
this.contentType = contentType
},
updateHtmlPreviewer(visible: boolean) {
this.showHtmlPreviewer = visible
},
updateTextEditor(visible: boolean) {
this.showTextEditor = visible
},
updateImagePreviewer(
visible: boolean,
imageUrls: string[] = [],
initialIndex: number = 0,
mjData?: any
) {
this.showImagePreviewer = visible
if (visible) {
this.previewImageUrls = imageUrls
this.initialImageIndex = initialIndex
this.mjImageData = mjData || {}
// 当图片预览器启用时,自动清空正在使用的插件
const chatStore = useChatStore()
chatStore.setUsingPlugin(null)
}
},
updateIsChatIn(isChatIn: boolean) {
this.isChatIn = isChatIn
},
updateGoodsDialog(goodsDialog: boolean) {
this.goodsDialog = goodsDialog
},
updateBindwxDialog(bindWxDialog: boolean) {
this.bindWxDialog = bindWxDialog
},
updateSignInDialog(signInDialog: boolean) {
this.signInDialog = signInDialog
},
updateNoticeDialog(noticeDialog: boolean) {
this.noticeDialog = noticeDialog
},
updateAppDialog(appDialog: boolean) {
this.appDialog = appDialog
},
updateIdentityDialog(identityDialog: boolean) {
this.identityDialog = identityDialog
},
updatePhoneDialog(phoneIdentityDialog: boolean) {
this.phoneIdentityDialog = phoneIdentityDialog
},
updateHtmlDialog(htmlDialog: boolean) {
this.htmlDialog = htmlDialog
},
updateModel(model: number) {
ss.set('model', model)
this.model = model
},
updateOrderInfo(info: any) {
// Add appropriate type
this.orderInfo = info
},
updatePythonDialog(pythonDialog: boolean) {
this.pythonDialog = pythonDialog
},
updatePythonContent(content: string) {
console.log('updatePythonContent', content)
this.pythonContent = content
},
updateExternalLinkDialog(visible: boolean, url: string | null = null) {
this.externalLinkDialog = visible
this.currentExternalLink = url
},
updateSettingsDialog(settingsDialog: boolean, activeTab?: number) {
this.settingsDialog = settingsDialog
if (settingsDialog && activeTab !== undefined) {
this.settingsActiveTab = activeTab
}
},
updateMobileSettingsDialog(mobileSettingsDialog: boolean, activeTab?: number | string) {
this.mobileSettingsDialog = mobileSettingsDialog
if (activeTab !== undefined) {
// 如果是数字索引转换为对应的tabId
if (typeof activeTab === 'number') {
const tabIds = ['account', 'member', 'notice', 'agreement']
this.mobileInitialTab = tabIds[activeTab] || undefined
} else {
// 如果直接传入了tabId字符串
this.mobileInitialTab = activeTab
}
} else {
this.mobileInitialTab = undefined
}
},
updateShowAppListComponent(showAppListComponent: boolean) {
this.showAppListComponent = showAppListComponent
},
setCurrentExternalLink(link: string | null) {
this.currentExternalLink = link
if (link) {
this.externalLinkDialog = true
}
},
updateSettingsActiveTab(tab: number) {
this.settingsActiveTab = tab
},
updateWorkflowPreviewer(visible: boolean) {
this.showWorkflowPreviewer = visible
if (!visible) {
this.workflowContent = []
}
},
addWorkflowContent(content: string) {
this.workflowContent.push(content)
},
clearWorkflowContent() {
this.workflowContent = []
},
updateWorkflowContentAt(index: number, content: string) {
if (index >= 0 && index < this.workflowContent.length) {
this.workflowContent[index] = content
}
},
updateWorkflowContentLast(newContent: string) {
const currentWorkflow = this.workflowContent
if (currentWorkflow.length > 0) {
// 获取最后一项的索引
const lastIndex = currentWorkflow.length - 1
// 将新内容追加到最后一项
this.workflowContent[lastIndex] += newContent
} else {
// 如果还没有内容,则创建新项
this.workflowContent.push(newContent)
}
},
updateMarkdownPreviewer(visible: boolean, markdownContent?: string) {
this.isMarkdownPreviewerVisible = visible
if (markdownContent) {
this.markdownContent = markdownContent
}
if (!visible) {
this.markdownContent = ''
}
},
},
})
export function useGlobalStoreWithOut() {
return useGlobalStore(store)
}

View File

@@ -0,0 +1,8 @@
export * from './app'
export * from './chat'
// export * from './user'
export * from './appStore'
export * from './auth'
export * from './global'
export * from './prompt'
export * from './settings'

View File

@@ -0,0 +1,19 @@
import { ss } from '@/utils/storage'
const LOCAL_NAME = 'promptStore'
export type PromptList = []
export interface PromptStore {
promptList: PromptList
}
export function getLocalPromptList(): PromptStore {
const storage = ss.get(LOCAL_NAME)
const promptStore: PromptStore | undefined = storage
return promptStore ?? { promptList: [] }
}
export function setLocalPromptList(promptStore: PromptStore): void {
ss.set(LOCAL_NAME, promptStore)
}

View File

@@ -0,0 +1,17 @@
import { defineStore } from 'pinia'
import type { PromptStore } from './helper'
import { getLocalPromptList, setLocalPromptList } from './helper'
export const usePromptStore = defineStore('prompt-store', {
state: (): PromptStore => getLocalPromptList(),
actions: {
updatePromptList(promptList: []) {
this.$patch({ promptList })
setLocalPromptList({ promptList })
},
getPromptList() {
return this.$state
},
},
})

View File

@@ -0,0 +1,26 @@
import { ss } from '@/utils/storage'
const LOCAL_NAME = 'settingsStorage'
export interface SettingsState {
systemMessage: string
}
export function defaultSetting(): SettingsState {
return {
systemMessage: '',
}
}
export function getLocalState(): SettingsState {
const localSetting: SettingsState | undefined = ss.get(LOCAL_NAME)
return { ...defaultSetting(), ...localSetting }
}
export function setLocalState(setting: SettingsState): void {
ss.set(LOCAL_NAME, setting)
}
export function removeLocalState() {
ss.remove(LOCAL_NAME)
}

View File

@@ -0,0 +1,22 @@
import { defineStore } from 'pinia'
import type { SettingsState } from './helper'
import { defaultSetting, getLocalState, removeLocalState, setLocalState } from './helper'
export const useSettingStore = defineStore('setting-store', {
state: (): SettingsState => getLocalState(),
actions: {
updateSetting(settings: Partial<SettingsState>) {
this.$state = { ...this.$state, ...settings }
this.recordState()
},
resetSetting() {
this.$state = defaultSetting()
removeLocalState()
},
recordState() {
setLocalState(this.$state)
},
},
})

View File

@@ -0,0 +1,30 @@
import { ss } from '@/utils/storage'
const LOCAL_NAME = 'userStorage'
export interface UserInfo {
avatar: string
name: string
}
export interface UserState {
userInfo: UserInfo
}
export function defaultSetting(): UserState {
return {
userInfo: {
avatar: '',
name: 'Ai Web',
},
}
}
export function getLocalState(): UserState {
const localSetting: UserState | undefined = ss.get(LOCAL_NAME)
return { ...defaultSetting(), ...localSetting }
}
export function setLocalState(setting: UserState): void {
ss.set(LOCAL_NAME, setting)
}

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