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

3
chat/.commitlintrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": ["@commitlint/config-conventional"]
}

8
chat/.env Normal file
View File

@@ -0,0 +1,8 @@
# 本地链接生产
# VITE_GLOB_API_URL=https://xxx.com/api
# 本地
VITE_GLOB_API_URL=http://127.0.0.1:9520/api
# 生产
# VITE_GLOB_API_URL=https://asst.lightai.cloud/api

9
chat/.env.production Normal file
View File

@@ -0,0 +1,9 @@
# 本地链接生产
# VITE_GLOB_API_URL=https://xxx.com/api
# 本地
VITE_GLOB_API_URL=/api
# 生产
# VITE_GLOB_API_URL=https://asst.lightai.cloud/api

17
chat/.gitattributes vendored Normal file
View File

@@ -0,0 +1,17 @@
"*.vue" eol=lf
"*.js" eol=lf
"*.ts" eol=lf
"*.jsx" eol=lf
"*.tsx" eol=lf
"*.cjs" eol=lf
"*.cts" eol=lf
"*.mjs" eol=lf
"*.mts" eol=lf
"*.json" eol=lf
"*.html" eol=lf
"*.css" eol=lf
"*.less" eol=lf
"*.scss" eol=lf
"*.sass" eol=lf
"*.styl" eol=lf
"*.md" eol=lf

32
chat/.gitignore vendored Normal file
View File

@@ -0,0 +1,32 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
/release
/electron
/dist
/resources
# Editor directories and files
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
chat/.npmrc Normal file
View File

@@ -0,0 +1,3 @@
strict-peer-dependencies=false
node-linker=hoisted
public-hoist-pattern[]=*

10
chat/.prettierrc Normal file
View File

@@ -0,0 +1,10 @@
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "avoid",
"endOfLine": "auto"
}

115
chat/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,115 @@
{
"prettier.enable": true,
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "explicit"
},
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"vue",
"html",
"json",
"jsonc",
"json5",
"yaml",
"yml",
"markdown"
],
"cSpell.words": [
"AIWEB",
"antfu",
"axios",
"Baichuan",
"bumpp",
"Chatbox",
"chatglm",
"chatgpt",
"chenzhaoyu",
"chevereto",
"cogvideox",
"commitlint",
"Crami",
"cref",
"dall",
"dalle",
"davinci",
"deepsearch",
"deepseek",
"dockerhub",
"Doubao",
"duckduckgo",
"Dulu",
"EMAILCODE",
"Epay",
"errmsg",
"esno",
"flowith",
"GPTAPI",
"gpts",
"headlessui",
"heroicons",
"highlightjs",
"hljs",
"hunyuan",
"Hupi",
"iconify",
"ISDEV",
"Jsapi",
"katex",
"katexmath",
"langchain",
"lightai",
"linkify",
"logprobs",
"longcontext",
"Ltzf",
"luma",
"mapi",
"Markmap",
"mdhljs",
"micromessenger",
"mila",
"Mindmap",
"MODELSMAPLIST",
"MODELTYPELIST",
"modelvalue",
"Mpay",
"newconfig",
"niji",
"Nmessage",
"nodata",
"OPENAI",
"pinia",
"Popconfirm",
"PPTCREATE",
"projectaddress",
"Pyodide",
"qwen",
"rushstack",
"sdxl",
"seededit",
"seedream",
"Sider",
"sref",
"suno",
"tailwindcss",
"Tavily",
"traptitech",
"tsup",
"Typecheck",
"typeorm",
"unplugin",
"usercenter",
"vastxie",
"VITE",
"vueuse",
"wechat",
"Weixin",
"wxpay"
],
"vue.codeActions.enabled": false
}

1
chat/config/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './proxy'

15
chat/config/proxy.ts Normal file
View File

@@ -0,0 +1,15 @@
import type { ProxyOptions } from 'vite'
export function createViteProxy(isOpenProxy: boolean, viteEnv: ImportMetaEnv) {
if (!isOpenProxy) return
const proxy: Record<string, string | ProxyOptions> = {
'/api': {
target: viteEnv.VITE_APP_API_BASE_URL,
changeOrigin: true,
rewrite: path => path.replace('/api/', '/'),
},
}
return proxy
}

59
chat/electron-builder.yml Normal file
View File

@@ -0,0 +1,59 @@
appId: com.99ai.chat
productName: 99AI
copyright: Copyright © 2024 vastxie
directories:
buildResources: resources
output: release
files:
- dist/**/*
- electron/**/*
- package.json
mac:
icon: resources/icon.icns
category: public.app-category.productivity
darkModeSupport: true
target:
- dmg
- zip
hardenedRuntime: false
gatekeeperAssess: false
entitlements: null
entitlementsInherit: null
artifactName: '${productName}-${version}-${arch}.${ext}'
dmg:
background: resources/background.png
icon: resources/icon.icns
iconSize: 128
contents:
- x: 380
y: 240
type: link
path: /Applications
- x: 122
y: 240
type: file
win:
icon: resources/icon.ico
target:
- nsis
- zip
artifactName: '${productName}-${version}-${arch}.${ext}'
linux:
icon: resources
category: Office
target:
- AppImage
- deb
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
createDesktopShortcut: true
createStartMenuShortcut: true
shortcutName: '99AI Chat'

9
chat/env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_API_BASE_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

34
chat/index.html Normal file
View File

@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="zh-cmn-Hans">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#ffffff">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<meta name="mobile-web-app-capable" content="yes">
<!-- <title>LightAI 助手</title> -->
<title>AIWeb</title>
<meta name="description" content="AIWeb 是一个集成化的人工智能服务站点,帮助用户高效完成各种任务。">
<meta name="keywords" content="AI, 助手, 智能助手, 任务管理, 高效, 知识库">
<link rel="apple-touch-icon" sizes="180x180" href="icon/apple-touch-icon.png">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="icon" type="image/png" sizes="32x32" href="icon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="icon/favicon-16x16.png">
<link rel="manifest" href="icon/manifest.webmanifest">
<link rel="mask-icon" href="icon/safari-pinned-tab.svg" color="#19c37d">
<script type="module" src="/src/main.ts"></script>
</head>
<body>
<div id="app"></div>
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
</body>
</html>

111
chat/package.json Normal file
View File

@@ -0,0 +1,111 @@
{
"name": "99ai-chat",
"version": "4.3.0",
"private": true,
"description": "99AI chat",
"author": "vastxie",
"keywords": [
"AIWeb",
"chatgpt",
"ChatBox",
"ChatWeb"
],
"main": "electron/main.js",
"scripts": {
"start:h": "pnpm run -C service dev",
"start:f": "vite",
"all": "npm-run-all --parallel start:h start:f",
"dev": "vite",
"build-check": "run-p type-check build-only",
"preview": "vite preview",
"build": "pnpm format && vite build --mode=production",
"type-check": "vue-tsc --noEmit",
"bootstrap": "pnpm install && pnpm run common:prepare",
"common:cleanup": "rimraf node_modules && rimraf pnpm-lock.yaml",
"format": "pnpm dlx prettier --write '{src,scripts,config}/**/*.{vue,ts,tsx,js,jsx,css,scss,less,json,md,html,yml,yaml}' '*.{js,ts,json,md,yml}'",
"electron": "electron .",
"electron:dev": "cross-env ELECTRON_START_URL=http://localhost:9002 electron .",
"electron:build": "pnpm run build && electron-builder",
"electron:mac": "pnpm run build && npx electron-builder --mac --config.npmRebuild=false",
"electron:mac-universal": "pnpm run build && npx electron-builder --mac --universal --config.npmRebuild=false",
"electron:win": "pnpm run build && electron-builder --win",
"electron:win-x64": "pnpm run build && electron-builder --win --x64",
"electron:win-arm64": "pnpm run build && electron-builder --win --arm64",
"electron:all": "pnpm run build && npx electron-builder --mac --universal --win --config.npmRebuild=false"
},
"dependencies": {
"@codemirror/lang-cpp": "^6.0.2",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.9",
"@codemirror/lang-java": "^6.0.1",
"@codemirror/lang-javascript": "^6.2.3",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-markdown": "^6.3.2",
"@codemirror/lang-php": "^6.0.1",
"@codemirror/lang-python": "^6.1.7",
"@codemirror/lang-rust": "^6.0.1",
"@codemirror/lang-sql": "^6.8.0",
"@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.2",
"@icon-park/vue-next": "^1.4.2",
"@opendocsg/pdf2md": "^0.2.1",
"@tailwindcss/typography": "^0.5.16",
"@traptitech/markdown-it-katex": "^3.6.0",
"@vueuse/core": "^13.1.0",
"@vueuse/integrations": "^13.1.0",
"@vueuse/motion": "^3.0.3",
"clientjs": "^0.2.1",
"codemirror": "^6.0.1",
"file-saver": "^2.0.5",
"highlight.js": "^11.11.1",
"html-to-image": "^1.11.13",
"html2pdf.js": "^0.10.3",
"jschardet": "^3.1.4",
"katex": "^0.16.22",
"mammoth": "^1.9.0",
"markdown-it": "^14.1.0",
"markdown-tables": "^3.0.5",
"markmap-common": "0.15.0",
"markmap-lib": "0.15.0",
"markmap-view": "0.15.0",
"md-editor-v3": "^4.21.3",
"mermaid": "^11.6.0",
"npx": "^10.2.2",
"office-text-extractor": "^3.0.3",
"pdfjs-dist": "^5.2.133",
"pinia": "^2.3.1",
"pinyin-match": "^1.2.6",
"pptxtojson": "^1.3.1",
"qrcode": "^1.5.4",
"tailwind-scrollbar": "^3.1.0",
"turndown": "^7.2.0",
"v-viewer": "3.0.21",
"vue": "^3.5.13",
"vue-i18n": "^11.1.3",
"vue-router": "^4.5.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@iconify/vue": "^4.3.0",
"@types/crypto-js": "^4.2.2",
"@types/katex": "^0.16.7",
"@types/markdown-table": "^3.0.0",
"@types/node": "^18.19.86",
"@types/turndown": "^5.0.5",
"@vitejs/plugin-vue": "^5.2.3",
"autoprefixer": "^10.4.21",
"axios": "^1.8.4",
"cross-env": "^7.0.3",
"crypto-js": "^4.2.0",
"less": "^4.3.0",
"markdown-it-link-attributes": "^4.0.1",
"npm-run-all": "^4.1.5",
"prettier": "^3.5.3",
"rimraf": "^4.4.1",
"tailwindcss": "^3.4.17",
"typescript": "~4.9.5",
"vite": "^4.5.14",
"vue-tsc": "^1.8.27"
},
"license": "MIT"
}

7455
chat/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

6
chat/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#da532c</TileColor>
</tile>
</msapplication>
</browserconfig>

BIN
chat/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,19 @@
{
"name": "",
"short_name": "",
"icons": [
{
"src": "icon/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "iocn/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
chat/public/pwa-192x192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

BIN
chat/public/pwa-512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

20
chat/public/robots.txt Normal file
View File

@@ -0,0 +1,20 @@
User-agent: *
Disallow: /api/
Disallow: /auth/
Allow: /
# Prevent indexing of sensitive paths
Disallow: /admin/
Disallow: /login
Disallow: /register
Disallow: /settings
Disallow: /user/
# Allow crawling of static assets
Allow: /assets/
Allow: /images/
Allow: /css/
Allow: /js/
# Crawl-delay
Crawl-delay: 10

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="1024.000000pt" height="1024.000000pt" viewBox="0 0 1024.000000 1024.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.14, written by Peter Selinger 2001-2017
</metadata>
<g transform="translate(0.000000,1024.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M2465 10233 c-551 -20 -907 -97 -1245 -269 -223 -113 -391 -237 -566
-416 -332 -340 -531 -754 -603 -1254 -46 -320 -46 -297 -46 -3174 0 -2877 0
-2854 46 -3174 75 -518 288 -947 641 -1292 339 -331 755 -531 1254 -603 320
-46 297 -46 3174 -46 2877 0 2854 0 3174 46 518 75 947 288 1292 641 331 339
531 755 603 1254 46 320 46 297 46 3174 0 2877 0 2854 -46 3174 -72 500 -271
914 -603 1254 -175 179 -343 303 -566 416 -284 145 -556 215 -990 257 -94 9
-792 13 -2805 14 -1474 1 -2716 0 -2760 -2z m2214 -3270 c123 -319 1407 -3677
1417 -3705 l13 -38 -333 0 -333 0 -128 353 c-70 193 -148 407 -172 475 l-45
122 -797 0 -796 0 -172 -473 -171 -472 -335 -3 -334 -2 726 1900 726 1900 356
0 356 0 22 -57z m2431 -1843 l0 -1900 -315 0 -315 0 0 1900 0 1900 315 0 315
0 0 -1900z"/>
<path d="M3997 5527 c-163 -453 -297 -829 -297 -835 0 -9 144 -12 606 -12 481
0 605 3 602 13 -3 6 -137 382 -299 834 -162 453 -299 823 -304 823 -6 0 -144
-370 -308 -823z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

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())
},
}

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