v4.3.0
3
chat/.commitlintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["@commitlint/config-conventional"]
|
||||
}
|
||||
8
chat/.env
Normal 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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,3 @@
|
||||
strict-peer-dependencies=false
|
||||
node-linker=hoisted
|
||||
public-hoist-pattern[]=*
|
||||
10
chat/.prettierrc
Normal 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
@@ -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
@@ -0,0 +1 @@
|
||||
export * from './proxy'
|
||||
15
chat/config/proxy.ts
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
6
chat/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
chat/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
9
chat/public/browserconfig.xml
Normal 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
|
After Width: | Height: | Size: 15 KiB |
BIN
chat/public/icon/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
chat/public/icon/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
chat/public/icon/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
chat/public/icon/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
chat/public/icon/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
19
chat/public/icon/manifest.webmanifest
Normal 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"
|
||||
}
|
||||
BIN
chat/public/icon/mstile-150x150.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
chat/public/pwa-192x192.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
chat/public/pwa-512x512.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
20
chat/public/robots.txt
Normal 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
|
||||
27
chat/public/safari-pinned-tab.svg
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,5 @@
|
||||
export interface ResData {
|
||||
success: boolean
|
||||
message: string
|
||||
data: any
|
||||
}
|
||||
30
chat/src/api/upload.ts
Normal 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
@@ -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参数
|
||||
})
|
||||
}
|
||||
BIN
chat/src/assets/aiavatar/360logo.png
Executable file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
chat/src/assets/aiavatar/alilogo.png
Executable file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
chat/src/assets/aiavatar/baidulogo.png
Executable file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
chat/src/assets/aiavatar/claudelogo.png
Executable file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
chat/src/assets/aiavatar/dalle.png
Executable file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
chat/src/assets/aiavatar/google.gif
Normal file
|
After Width: | Height: | Size: 271 KiB |
BIN
chat/src/assets/aiavatar/gpt4logo.png
Executable file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
chat/src/assets/aiavatar/midjourney.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
chat/src/assets/aiavatar/mindmap.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
chat/src/assets/aiavatar/network.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
1
chat/src/assets/aiavatar/openai.svg
Normal 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 |
BIN
chat/src/assets/aiavatar/sdxl.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
chat/src/assets/aiavatar/suno.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
chat/src/assets/aiavatar/tencentlogo.png
Executable file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
chat/src/assets/aiavatar/xunfeilogo.png
Executable file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
chat/src/assets/aiavatar/zhipulogo.png
Executable file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
chat/src/assets/alipay.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
chat/src/assets/avatar.png
Normal file
|
After Width: | Height: | Size: 255 KiB |
BIN
chat/src/assets/badge.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
710
chat/src/assets/defaultPreset.json
Normal 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
|
After Width: | Height: | Size: 9.9 KiB |
BIN
chat/src/assets/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
chat/src/assets/logo.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
chat/src/assets/market.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
chat/src/assets/reset.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
chat/src/assets/wechat.png
Normal file
|
After Width: | Height: | Size: 662 B |
BIN
chat/src/assets/wxpay.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
101
chat/src/components/BadWordsDialog.vue
Normal 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>
|
||||
92
chat/src/components/CloseButtonDemo.vue
Normal 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>
|
||||
99
chat/src/components/Dialog/Confirm.vue
Normal 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>
|
||||
101
chat/src/components/Dialogs/BadWordsDialog.vue
Normal 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>
|
||||
327
chat/src/components/HtmlDialog.vue
Normal 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>
|
||||
165
chat/src/components/Identity.vue
Normal 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>
|
||||
390
chat/src/components/Login/Email.vue
Normal 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>
|
||||
109
chat/src/components/Login/Login.vue
Normal 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>
|
||||
819
chat/src/components/Login/SliderCaptcha.vue
Normal 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>
|
||||
159
chat/src/components/Login/Wechat.vue
Normal 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>
|
||||
89
chat/src/components/Message/index.vue
Normal 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>
|
||||
228
chat/src/components/MobileSettingsDialog.vue
Normal 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>
|
||||
284
chat/src/components/PhoneIdentity.vue
Normal 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>
|
||||
1164
chat/src/components/Settings/AccountManagement.vue
Normal file
97
chat/src/components/Settings/DataManagement.vue
Normal 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>
|
||||
782
chat/src/components/Settings/MemberCenter.vue
Normal 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>
|
||||
482
chat/src/components/Settings/MemberPayment.vue
Normal 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>
|
||||
100
chat/src/components/Settings/NoticeDialog.vue
Normal 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>
|
||||
78
chat/src/components/Settings/UserAgreement.vue
Normal 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>
|
||||
228
chat/src/components/SettingsDialog.vue
Normal 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>
|
||||
120
chat/src/components/common/DropdownMenu/MenuItem.vue
Normal 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>
|
||||
5
chat/src/components/common/DropdownMenu/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import DropdownMenu from './index.vue'
|
||||
import MenuItem from './MenuItem.vue'
|
||||
|
||||
export { DropdownMenu, MenuItem }
|
||||
export default DropdownMenu
|
||||
280
chat/src/components/common/DropdownMenu/index.vue
Normal 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>
|
||||
19
chat/src/components/common/ImageViewer/GlobalImageViewer.vue
Normal 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>
|
||||
490
chat/src/components/common/ImageViewer/index.vue
Normal 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>
|
||||
55
chat/src/components/common/ImageViewer/useImageViewer.ts
Normal 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())
|
||||
},
|
||||
}
|
||||