初始化

This commit is contained in:
梁泽军 2025-03-07 10:31:57 +08:00
commit dbf485e0d5
552 changed files with 81015 additions and 0 deletions

3
.env.development.example Normal file
View File

@ -0,0 +1,3 @@
# 请求域名
VITE_APP_BASE_URL=''

3
.env.production.example Normal file
View File

@ -0,0 +1,3 @@
# 请求域名
VITE_APP_BASE_URL=''

39
.eslintrc.js Normal file
View File

@ -0,0 +1,39 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
root: true,
ignorePatterns: ['src/uni_modules/'],
extends: [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-typescript/recommended',
'@vue/eslint-config-prettier'
],
rules: {
'prettier/prettier': [
'warn',
{
semi: false,
singleQuote: true,
printWidth: 100,
proseWrap: 'preserve',
bracketSameLine: false,
endOfLine: 'lf',
tabWidth: 4,
useTabs: false,
trailingComma: 'none'
}
],
'vue/multi-word-component-names': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'no-undef': 'off',
'vue/prefer-import-from-vue': 'off',
'no-prototype-builtins': 'off',
'prefer-spread': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-non-null-asserted-optional-chain': 'off'
},
globals: {}
}

31
.gitignore vendored Normal file
View File

@ -0,0 +1,31 @@
# 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/
# Editor directories and files
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.hbuilderx
# .env
.env.development
.env.production

3
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"]
}

11
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,11 @@
{
"editor.detectIndentation": false,
"editor.tabSize": 4,
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"css.validate": false,
"less.validate": false,
"scss.validate": false
}

21
index.html Normal file
View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<script>
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
CSS.supports('top: constant(a)'))
document.write(
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
(coverSupport ? ', viewport-fit=cover' : '') + '" />')
</script>
<title></title>
<!--preload-links-->
<!--app-context-->
</head>
<body>
<div id="app"><!--app-html--></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1
initialize.js Normal file
View File

@ -0,0 +1 @@
const fs = require('fs') const { spawn } = require('child_process') class InitializeItem { static instance = null constructor() { if (InitializeItem.instance) { return InitializeItem.instance } InitializeItem.instance = this } async promptUser(question) { return new Promise((resolve, reject) => { const readline = require('readline') const rl = readline.createInterface({ input: process.stdin, output: process.stdout }) rl.question(question, (res) => { resolve(res) rl.close() }) }) } async shouldInstallDependencies() { const isInstall = await this.promptUser( '是否需要自动帮您安装依赖y/n' ) if (isInstall.toLowerCase() === 'y') { return true } else if (isInstall.toLowerCase() === 'n') { return false } else { return this.shouldInstallDependencies() } } async installDependencies() { return new Promise((resolve, reject) => { console.log('开始安装相关依赖...') const command = process.platform === 'win32' ? 'cmd.exe' : 'npm' const args = process.platform === 'win32' ? ['/c', 'npm', 'install'] : ['install'] const installProcess = spawn(command, args) installProcess.stdout.on('data', (data) => { console.log(data.toString()) }) installProcess.stderr.on('data', (data) => { console.error(data.toString()) }) installProcess.on('close', (code) => { if (code !== 0) { reject( new Error( `运行安装依赖命令错误,请查看以下报错信息寻找解决方法: ${error.message}` ) ) } else { console.log('安装依赖成功!') resolve() } }) }) } async copyFile(sourceDir, targetDir) { return new Promise((resolve, reject) => { fs.copyFile(sourceDir, targetDir, (error) => { if (error) { reject(error) throw new Error(`复制文件失败: ${error.message}`) } resolve() }) }) } async writeToFile(filePath, { sourceData, targetData }) { return new Promise((resolve, reject) => { fs.readFile(filePath, 'utf8', (err, data) => { if (err) { console.error('读取文件失败:', err) return } const modifiedData = data.replace(sourceData, targetData) fs.writeFile(filePath, modifiedData, 'utf8', (err) => { if (err) { console.error('写入文件错误:', err) return } resolve() }) }) }) } async initialize(targetVersion) { const currentVersion = process.versions.node if (currentVersion < targetVersion) { throw new Error( `你的当前node版本为(${currentVersion}),需要安装目标版本为 ${targetVersion} 以上!!` ) } const shouldInstall = await this.shouldInstallDependencies() if (shouldInstall) { await this.installDependencies() } await this.copyFile('.env.development.example', '.env.development') await this.copyFile('.env.production.example', '.env.production') const domain = await this.promptUser('请输入您的服务器域名地址:') await this.writeToFile('.env.development', { sourceData: `VITE_APP_BASE_URL=''`, targetData: `VITE_APP_BASE_URL='${domain}'` }) await this.writeToFile('.env.production', { sourceData: `VITE_APP_BASE_URL=''`, targetData: `VITE_APP_BASE_URL='${domain}'` }) require('./scripts/develop'); } static getInstance() { if (!InitializeItem.instance) { InitializeItem.instance = new InitializeItem() } return InitializeItem.instance } } ;(async () => { const initializeItem = InitializeItem.getInstance() try { await initializeItem.initialize('16.16.0') } catch (error) { console.error(error.message) } })()

BIN
my-release-key.keystore Normal file

Binary file not shown.

108
package.json Normal file
View File

@ -0,0 +1,108 @@
{
"name": "uni-preset-vue",
"version": "0.0.0",
"scripts": {
"init": "node initialize.js",
"dev": "node scripts/develop.js",
"dev:app": "uni -p app",
"dev:custom": "uni -p",
"dev:h5": "uni",
"dev:h5:ssr": "uni --ssr",
"dev:mp-alipay": "uni -p mp-alipay",
"dev:mp-baidu": "uni -p mp-baidu",
"dev:mp-kuaishou": "uni -p mp-kuaishou",
"dev:mp-lark": "uni -p mp-lark",
"dev:mp-qq": "uni -p mp-qq",
"dev:mp-toutiao": "uni -p mp-toutiao",
"dev:mp-weixin": "uni -p mp-weixin",
"dev:quickapp-webview": "uni -p quickapp-webview",
"dev:quickapp-webview-huawei": "uni -p quickapp-webview-huawei",
"dev:quickapp-webview-union": "uni -p quickapp-webview-union",
"build": "node scripts/publish.js",
"build:app": "uni build -p app",
"build:custom": "uni build -p",
"build:h5": "uni build && node scripts/release.mjs -t h5 -o mobile",
"build:h5:ssr": "uni build --ssr",
"build:mp-alipay": "uni build -p mp-alipay",
"build:mp-baidu": "uni build -p mp-baidu",
"build:mp-kuaishou": "uni build -p mp-kuaishou",
"build:mp-lark": "uni build -p mp-lark",
"build:mp-qq": "uni build -p mp-qq",
"build:mp-toutiao": "uni build -p mp-toutiao",
"build:mp-weixin": "uni build -p mp-weixin && node scripts/release.mjs -t mp-weixin -o weapp",
"build:quickapp-webview": "uni build -p quickapp-webview",
"build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei",
"build:quickapp-webview-union": "uni build -p quickapp-webview-union",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
},
"dependencies": {
"@dcloudio/uni-app": "3.0.0-3070920230324001",
"@dcloudio/uni-app-plus": "3.0.0-3070920230324001",
"@dcloudio/uni-components": "3.0.0-3070920230324001",
"@dcloudio/uni-h5": "3.0.0-3070920230324001",
"@dcloudio/uni-mp-alipay": "3.0.0-3070920230324001",
"@dcloudio/uni-mp-baidu": "3.0.0-3070920230324001",
"@dcloudio/uni-mp-jd": "3.0.0-3070920230324001",
"@dcloudio/uni-mp-kuaishou": "3.0.0-3070920230324001",
"@dcloudio/uni-mp-lark": "3.0.0-3070920230324001",
"@dcloudio/uni-mp-qq": "3.0.0-3070920230324001",
"@dcloudio/uni-mp-toutiao": "3.0.0-3070920230324001",
"@dcloudio/uni-mp-weixin": "3.0.0-3070920230324001",
"@dcloudio/uni-quickapp-webview": "3.0.0-3070920230324001",
"@dcloudio/uni-webview-js": "0.0.3",
"@iktakahiro/markdown-it-katex": "4.0.1",
"@vueuse/core": "9.8.2",
"css-color-function": "1.3.3",
"github-markdown-css": "5.2.0",
"highlight.js": "11.0.0",
"howler": "2.2.4",
"js-mp3": "0.1.0",
"jsonc-parser": "3.2.1",
"js-base64": "^3.7.5",
"lodash-es": "4.17.21",
"markdown-it": "^13.0.1",
"markdown-it-math": "4.1.1",
"markmap-common": "0.15.3",
"markmap-lib": "0.15.4",
"markmap-view": "0.15.4",
"mathjs": "11.8.0",
"pinia": "2.0.20",
"recorder-core": "1.3.23122400",
"uniapp-router-next": "1.2.7",
"uniapp-router-next-zm": "^1.0.1",
"vconsole": "3.14.6",
"vue": "3.2.45",
"vue-i18n": "9.1.9",
"weixin-js-sdk": "1.6.0",
"z-paging": "2.7.6"
},
"devDependencies": {
"@dcloudio/types": "^3.3.2",
"@dcloudio/uni-automator": "3.0.0-3070920230324001",
"@dcloudio/uni-cli-shared": "3.0.0-3070920230324001",
"@dcloudio/uni-stacktracey": "3.0.0-3070920230324001",
"@dcloudio/vite-plugin-uni": "3.0.0-3070920230324001",
"@rushstack/eslint-patch": "1.1.4",
"@types/howler": "2.2.11",
"@types/lodash-es": "4.17.6",
"@types/markdown-it": "12.2.3",
"@types/node": "18.7.16",
"@vue/eslint-config-prettier": "7.0.0",
"@vue/eslint-config-typescript": "11.0.0",
"autoprefixer": "10.4.8",
"eslint": "8.22.0",
"eslint-plugin-vue": "9.4.0",
"execa": "6.1.0",
"fs-extra": "10.1.0",
"minimist": "1.2.8",
"postcss": "8.4.16",
"postcss-rem-to-responsive-pixel": "5.1.3",
"prettier": "2.7.1",
"sass": "1.54.5",
"tailwindcss": "3.3.2",
"typescript": "4.7.4",
"unplugin-uni-router": "1.2.7",
"vite": "4.1.4",
"weapp-tailwindcss-webpack-plugin": "1.12.8"
}
}

105
scripts/develop.js Normal file
View File

@ -0,0 +1,105 @@
const { spawn } = require('child_process')
const readline = require('readline')
class DevelopClientScript {
constructor() {
if (DevelopClientScript.instance) {
return DevelopClientScript.instance
}
DevelopClientScript.instance = this
}
promptUser(question) {
return new Promise((resolve) => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
})
rl.question(question, (res) => {
resolve(res)
rl.close()
})
})
}
async runClient() {
console.error('请选择你需要运行的客户端(回复数字后回车)')
console.error('0.取消')
console.error('1.微信小程序')
console.error('2.公众号或者H5')
const runClientRes = await this.promptUser('请输入运行的客户端:')
switch (runClientRes) {
case '0':
break
case '1':
await this.runNpmScript('dev:mp-weixin')
break
case '2':
await this.runNpmScript('dev:h5')
break
default:
await this.runClient()
break
}
}
runNpmScript(scriptName) {
return new Promise((resolve, reject) => {
const isWindows = process.platform === 'win32'
const command = isWindows ? 'cmd.exe' : 'npm'
const args = isWindows
? ['/c', 'npm', 'run', scriptName]
: ['run', scriptName]
const runProcess = spawn(command, args)
runProcess.stdout.on('data', (data) => {
console.log(data.toString())
})
runProcess.stderr.on('data', (data) => {
console.error(data.toString())
})
runProcess.on('close', (code) => {
if (code !== 0) {
reject(
new Error(
`运行错误,请查看以下报错信息寻找解决方法: ${error.message}`
)
)
} else {
resolve()
}
})
})
}
async run(targetVersion) {
const currentVersion = process.versions.node
if (currentVersion < targetVersion) {
throw new Error(
`你的当前node版本为(${currentVersion}),需要安装目标版本为 ${targetVersion} 以上!!`
)
}
await this.runClient()
}
static getInstance() {
if (!DevelopClientScript.instance) {
DevelopClientScript.instance = new DevelopClientScript()
}
return DevelopClientScript.instance
}
}
;(async () => {
const develop = DevelopClientScript.getInstance()
try {
await develop.run('16.16.0')
} catch (error) {
console.error(error.message)
}
})()

1
scripts/publish.js Normal file
View File

@ -0,0 +1 @@
const { spawn } = require('child_process') const readline = require('readline') class PublishClientScript { constructor() { if (PublishClientScript.instance) { return PublishClientScript.instance } PublishClientScript.instance = this } promptUser(question) { return new Promise((resolve) => { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }) rl.question(question, (res) => { resolve(res) rl.close() }) }) } async runClient() { console.error('请选择你需要打包的客户端(回复数字后回车) ') console.error('0.取消') console.error('1.微信小程序') console.error('2.公众号或者H5') const runClientRes = await this.promptUser('请输入打包的客户端:') switch (runClientRes) { case '0': break case '1': await this.runNpmScript('build:mp-weixin') break case '2': await this.runNpmScript('build:h5') break default: await this.runClient() break } } runNpmScript(scriptName) { return new Promise((resolve, reject) => { const isWindows = process.platform === 'win32' const command = isWindows ? 'cmd.exe' : 'npm' const args = isWindows ? ['/c', 'npm', 'run', scriptName] : ['run', scriptName] const runProcess = spawn(command, args) runProcess.stdout.on('data', (data) => { console.log(data.toString()) }) runProcess.stderr.on('data', (data) => { console.error(data.toString()) }) runProcess.on('close', (code) => { if (code !== 0) { reject( new Error( `运行错误,请查看以下报错信息寻找解决方法: ${error.message}` ) ) } else { resolve() } }) }) } async run(targetVersion) { const currentVersion = process.versions.node if (currentVersion < targetVersion) { throw new Error( `你的当前node版本为(${currentVersion}),需要安装目标版本为 ${targetVersion} 以上!!` ) } await this.runClient() } static getInstance() { if (!PublishClientScript.instance) { PublishClientScript.instance = new PublishClientScript() } return PublishClientScript.instance } } ;(async () => { const publish = PublishClientScript.getInstance() try { await publish.run('16.16.0') } catch (error) { console.error(error.message) } })()

46
scripts/release.mjs Normal file
View File

@ -0,0 +1,46 @@
import path from 'path'
import fsExtra from 'fs-extra'
import minimist from 'minimist'
const { existsSync, remove, copy } = fsExtra
const cwd = process.cwd()
const argv = minimist(process.argv.slice(2), {
alias: {
target: 't',
output: 'o'
}
})
//打包发布路径,谨慎改动
const releaseRelativePath = `../public/${argv.output}`
const distPath = path.resolve(cwd, `dist/build/${argv.target}`)
const releasePath = path.resolve(cwd, releaseRelativePath)
async function build() {
if (existsSync(releasePath)) {
await remove(releasePath)
}
console.log(
`文件正在复制dist/build/${argv.target} ==> ${releaseRelativePath}`
)
try {
await copyFile(distPath, releasePath)
} catch (error) {
console.log(`\n ${error}`)
}
console.log(
`文件已复制dist/build/${argv.target} ==> ${releaseRelativePath}`
)
}
function copyFile(sourceDir, targetDir) {
return new Promise((resolve, reject) => {
copy(sourceDir, targetDir, (err) => {
if (err) {
reject(err)
} else {
resolve()
}
})
})
}
build()

76
src/App.vue Normal file
View File

@ -0,0 +1,76 @@
<script setup lang="ts">
import { onLaunch, onShow } from '@dcloudio/uni-app'
import { useAppStore } from './stores/app'
import { useUserStore } from './stores/user'
import { useThemeStore } from '@/stores/theme'
import { addVisit } from './api/shop'
import { useSharedId } from './hooks/useShareMessage'
import { SHARE_ID, USER_SN } from './enums/constantEnums'
import { strToParams } from './utils/util'
import cache from './utils/cache'
const appStore = useAppStore()
const { getUser } = useUserStore()
const { getTheme } = useThemeStore()
const cacheInvite = (query: any = {}) => {
const { share_id } = query
const user_sn =
query.user_sn ||
strToParams(decodeURIComponent(query['scene']))['user_sn'];
console.log(query)
console.log(user_sn)
if (share_id) {
cache.set(SHARE_ID, share_id)
}
if (user_sn) {
cache.set(USER_SN, user_sn)
}
}
//#ifdef H5
const setH5WebIcon = () => {
const config = appStore.getWebsiteConfig
let favicon: HTMLLinkElement = document.querySelector('link[rel="icon"]')!
if (favicon) {
favicon.href = config.logo
return
}
favicon = document.createElement('link')
favicon.rel = 'icon'
favicon.href = config.logo
document.head.appendChild(favicon)
}
//#endif
const getConfig = async () => {
await appStore.getConfig()
//h5
// #ifdef H5
const { status, close, url } = appStore.getH5Config
if (status == 0) {
if (close == 1) return (location.href = url)
uni.reLaunch({ url: '/pages/empty/empty' })
}
// #endif
}
onLaunch(async (opinion) => {
getConfig()
getTheme()
await addVisit()
getUser()
useSharedId()
cacheInvite(opinion?.query)
//#ifdef H5
setH5WebIcon()
//#endif
})
onShow(() => {
uni.hideTabBar()
})
</script>
<style lang="scss">
//
</style>

40
src/androidPrivacy.json Normal file
View File

@ -0,0 +1,40 @@
{
"version" : "1",
"prompt" : "template",
"title" : "用户协议及隐私政策",
"message" : "  为了更好保证您的合法权益,请仔细阅读并同意以下协议。<br/><a href=\"https://chat.mddai.cn/mobile/packages/pages/agreement/agreement?type=service\">《用户协议》</a>和<a href=\"https://chat.mddai.cn/mobile/packages/pages/agreement/agreement?type=privacy\">《隐私政策》</a>",
"buttonAccept" : "同意并接受",
"buttonRefuse" : "暂不同意",
"hrefLoader" : "system|default",
"backToExit" : "false",
"second" : {
"title" : "确认提示",
"message" : "  进入应用前,你需先同意<a href=\"https://chat.mddai.cn/mobile/packages/pages/agreement/agreement?type=service\">《用户协议》</a>和<a href=\"https://chat.mddai.cn/mobile/packages/pages/agreement/agreement?type=privacy\">《隐私政策》</a>,否则将退出应用。",
"buttonAccept" : "同意并继续",
"buttonRefuse" : "退出应用"
},
"disagreeMode" : {
"support" : false,
"loadNativePlugins" : false,
"visitorEntry" : true,
"showAlways" : false
},
"styles" : {
"backgroundColor" : "#ffffff",
"borderRadius" : "5px",
"title" : {
"color" : "#000000",
"fontWeight" : "bolder"
},
"buttonAccept" : {
"color" : "#000000"
},
"buttonRefuse" : {
"color" : "#000000",
"backgroundColor" : "#FFB529"
},
"buttonVisitor" : {
"color" : "#000000"
}
}
}

74
src/api/account.ts Normal file
View File

@ -0,0 +1,74 @@
import request from '@/utils/request'
import { getClient } from '@/utils/client'
// 登录
export function mobileLogin(data: Record<string, any>) {
return request.post({ url: '/login/mobileLogin', data })
}
// 账号登录
export function accountLogin(data: Record<string, any>) {
return request.post({ url: '/login/accountLogin', data })
}
// 邮箱登录
export function emailLogin(data: any) {
return request.post({
url: '/login/emailLogin',
data: { ...data, terminal: getClient() }
})
}
//邮箱注册
export function emailRegister(data: any) {
return request.post({
url: '/login/email/register',
data
})
}
//发送邮箱验证码
export function sendEmailCode(data: any) {
return request.post({ url: '/index/sendEmail', data })
}
//注册
export function register(data: Record<string, any>) {
return request.post({ url: '/login/register', data })
}
//忘记密码
export function forgotPassword(data: Record<string, any>) {
return request.post({ url: '/user/forgotPwd', data })
}
//邮箱忘记密码
export function emailForgotPassword(data: Record<string, any>) {
return request.post({ url: '/user/email/forgotPwd', data })
}
//向微信请求code的链接
export function getWxCodeUrl(data: Record<string, any>) {
return request.get({ url: '/login/oaCodeUrl', data })
}
// 微信小程序登录
export function mnpLogin(data: Record<string, any>) {
return request.post({ url: '/login/mnpLogin', data })
}
// APP登录
export function uninAppLogin(data: Record<string, any>) {
return request.post({ url: '/login/appLogin', data })
}
// 公众号登录
export function OALogin(data: Record<string, any>) {
return request.post({ url: '/login/oaLogin', data })
}
//获取图形验证码
export function captcha() {
return request.get({ url: '/login/captcha' })
}

48
src/api/app.ts Normal file
View File

@ -0,0 +1,48 @@
import wechatOa from '@/utils/wechat'
import request from '@/utils/request'
//发送短信
export function smsSend(data: any) {
return request.post({ url: '/index/sendSms', data: data })
}
export function getConfig(data: any) {
return request.get({ url: '/index/config', data })
}
export function getPolicy(data: any) {
return request.get({ url: '/index/policy', data: data })
}
export function uploadImage(file: any, token?: string) {
return request.uploadFile({
url: '/upload/image',
filePath: file,
name: 'file',
header: {
token
},
fileType: 'image'
})
}
export function uploadFile(
type: 'image' | 'file' | 'video',
options: Omit<UniApp.UploadFileOption, 'url'>,
onProgress?: (progress: number) => void
) {
return request.uploadFile(
{ ...options, url: `/upload/${type}`, name: 'file' },
{
onProgress
}
)
}
export function wxJsConfig(data: any) {
return request.get({ url: '/wechat/jsConfig', data })
}
export function getMnpQrCode(data: any) {
return request.post({ url: '/wechat/getMnpQrCode', data: data })
}

130
src/api/chat.ts Normal file
View File

@ -0,0 +1,130 @@
import request, { RequestEventStreamConfig } from '@/utils/request'
//获取技能列表
export function getSkillLists(data: any) {
return request.get({ url: '/ai/skill', data })
}
//获取创作列表
export function getCreationLists(data: { keyword: string }) {
return request.get({ url: '/ai/creation', data })
}
export function getSamplesLists() {
return request.get({ url: '/ai/question' })
}
export function questionChat(data: any) {
return request.post({ url: '/chat_records/chat', data }, { isAuth: true })
}
// 对话记录
export function getChatRecord(data: any) {
return request.get({
url: '/chats/chatRecord',
data
})
}
//清空会话
export function cleanChatRecord(data: any) {
return request.post(
{
url: '/chats/chatClean',
data
},
{ isAuth: true }
)
}
//清空会话
export function delChatRecord(data: any) {
return request.post(
{
url: '/chats/record/del',
data
},
{ isAuth: true }
)
}
//收藏
export function collectChatRecord(data: any) {
return request.post({ url: '/chats/addCollect', data }, { isAuth: true })
}
//取消收藏
export function cancelCollectChatRecord(data: any) {
return request.post({ url: '/chats/cancelCollect', data }, { isAuth: true })
}
//收藏列表
export function getCollectChatRecordLists(data: any) {
return request.get({ url: '/chats/listCollect', data })
}
export function getCreationDetail(data: any) {
return request.get({ url: '/ai/creation/detail', data })
}
export function getSkillDetail(data: any) {
return request.get({ url: '/ai/skill/detail', data })
}
export function chatSendText(data: any, config: RequestEventStreamConfig) {
return request.eventStream({ url: '/chats/chatSend', data, method: 'POST' }, config)
}
// 对话分类列表
export function getChatCategoryLists(data: any) {
return request.get({ url: '/ai/category', data })
}
// 对话分类新增
export function chatCategoryAdd(data: any) {
return request.post({ url: '/ai/category/add', data }, { isAuth: true })
}
// 对话分类编辑
export function chatCategoryEdit(data: any) {
return request.post({ url: '/ai/category/update', data }, { isAuth: true })
}
// 对话分类删除
export function chatCategoryDelete(data: any) {
return request.post({ url: '/ai/category/del', data }, { isAuth: true })
}
// 对话分类清空
export function chatCategoryClear() {
return request.post({ url: '/ai/category/delAll' }, { isAuth: true })
}
// 创作
export function creationChat(data: any, config: RequestEventStreamConfig) {
return request.eventStream({ url: '/chat/creationChat', data, method: 'POST' }, config)
}
//获取聊天模型接口
export function getChatModelApi() {
return request.get({ url: '/chats/getChatBillingConfigList' })
}
export function getChatBroadcast(data: any) {
return request.post({ url: '/voice/execute', data })
}
export function getChatVoiceGenerate(data: any) {
return request.post({ url: '/chats/voiceGenerate', data })
}
export function audioTransfer(filePath: string, formData: Record<string, any>) {
return request.uploadFile({
url: '/voice/voiceTransfer',
filePath,
formData,
name: 'file'
})
}
export function getPlugLists() {
return request.get({ url: '/chats/plugLists' })
}

1
src/api/drawing.ts Normal file
View File

@ -0,0 +1 @@
import request, { RequestEventStreamConfig } from '@/utils/request' export type DrawingFormType = { prompt: string // 是 关键词 action: string // 是 操作 generate=生成图片 upsample{index}=放大 variation{index}=变换 imageBase: string // 否 图片地址 图生成图时必填 imageId: string // 否 图片id 图片放大或变换时必填 model: string // 是 绘画的模型 version: string // 是 版本 scale: string // 否 图片比例 no_content: string // 否 忽略的关键词 other: string // 否 其它参数 style: string // 否 风格 动漫-default 可爱-cute 丰富-expressive 风景-scenic engine: string // 否 意间sd-绘画引擎 quality: string // 否 DALLE-3 画质 } // 生成图片 export function drawing(data: DrawingFormType) { return request.post({ url: '/draw/execute', data, method: 'POST' }) } // 生成图片详情 export function drawingDetail(data: number[]) { return request.post({ url: '/draw/records/detail', data }) } // 生成图片记录 export function drawingRecord(data: any) { return request.get({ url: '/draw/records/list', data }) } // 删除 export function drawingDelete(data: number[]) { return request.post({ url: '/draw/records/del', data, method: 'POST' }) } // 关键词分类 export function keywordCate() { return request.get({ url: '/draw/category/get' }) } // 关键词 export function keywordPrompt(data: any) { return request.get({ url: '/draw/prompt/get', data }) } // 绘画示例 export function drawingExample() { return request.get({ url: '/draw/prompt/example/get' }) } // 关键词翻译 export function keywordPromptTranslate(data: any) { return request.post({ url: '/draw/translate', data, method: 'POST' }) } // 绘画模型 export function drawingModel() { return request.get({ url: '/draw/getDrawBillingConfig' }) } // 意间绘画风格选择 export function yjStyleSelector() { return request.get({ url: '/draw/getSelector' }) } // SD export function sdModelList() { return request.get({ url: '/draw/sd/getModel' }) }

17
src/api/member.ts Normal file
View File

@ -0,0 +1,17 @@
import request from '@/utils/request'
export function getMemberLists() {
return request.get({ url: '/member/packageList' }, { isAuth: true })
}
export function getCommentLists(data: any) {
return request.get({ url: '/member/commentList', data }, { isAuth: true })
}
export function memberBuy(data: any) {
return request.post({ url: '/member/buy', data }, { isAuth: true })
}
export function getMemberBuyLog(data?: any) {
return request.get({ url: '/member/buyLog', data }, { isAuth: true })
}

52
src/api/news.ts Normal file
View File

@ -0,0 +1,52 @@
import request from '@/utils/request'
/**
* @description
* @return { Promise }
*/
export function getArticleCate() {
return request.get({ url: '/article/category' })
}
/**
* @description
* @return { Promise }
*/
export function getArticleList(data: Record<string, any>) {
return request.get({ url: '/article/list', data: data })
}
/**
* @description
* @param { number } id
* @return { Promise }
*/
export function getArticleDetail(data: { id: number }) {
return request.get({ url: '/article/detail', data: data })
}
/**
* @description
* @param { number } articleId
* @return { Promise }
*/
export function addCollect(data: { articleId: number }) {
return request.post({ url: '/article/collectAdd', data: data }, { isAuth: true })
}
/**
* @description
* @param { number } id
* @return { Promise }
*/
export function cancelCollect(data: { articleId: number }) {
return request.post({ url: '/article/collectCancel', data: data }, { isAuth: true })
}
/**
* @description
* @return { Promise }
*/
export function getCollect() {
return request.get({ url: '/article/collectList' })
}

16
src/api/pay.ts Normal file
View File

@ -0,0 +1,16 @@
import request from '@/utils/request'
//支付方式
export function getPayWay(data: any) {
return request.get({ url: '/pay/payWay', data }, { isAuth: true })
}
// 预支付
export function prepay(data: any) {
return request.post({ url: '/pay/prepay', data }, { isAuth: true })
}
// 预支付
export function getPayResult(data: any) {
return request.get({ url: '/pay/payStatus', data }, { isAuth: true })
}

55
src/api/promotion.ts Normal file
View File

@ -0,0 +1,55 @@
import request from '@/utils/request'
// 分销中心
export function getDistributionIndex(data?: any) {
return request.get({ url: '/distribution/center', data }, { isAuth: true })
}
// 分销申请
export function distributionApply(data: any) {
return request.post(
{ url: '/distribution/apply', data },
{ isAuth: true }
)
}
// 分销订单
export function distributionOrder(data?: any) {
return request.get(
{ url: '/distribution/order/list', data },
{ isAuth: true }
)
}
// 粉丝列表
export function distributionFans(data?: any) {
return request.get(
{ url: '/distribution/user/invite/list', data },
{ isAuth: true }
)
}
// 提现 ---------------------------------------------------------------------------------
export type WithdrawApplyType = {
money: string | number // 提现金额
account: string | number // 支付宝账号
realName: string | number // 支付宝姓名
type: string | number
moneyQrcode: string[] | string
}
// 提现申请
export function withdrawApply(data: WithdrawApplyType) {
return request.post({ url: '/distribution/withdrawal/apply', data }, { isAuth: true })
}
// 提现记录
export function withdrawList(data?: { pageNo: number; pageSize: number }) {
return request.get({ url: '/distribution/pc/withdrawal/list', data }, { isAuth: true })
}
export function accountLog(data?: any) {
return request.get(
{ url: '/distribution/getRevenueDetails', data },
{ isAuth: true }
)
}

60
src/api/qrcode.ts Normal file
View File

@ -0,0 +1,60 @@
import request from '@/utils/request'
export type QrcodeFormType = {
model: string // 是 绘画模型
type: number // 是 生成模式 1-文本模式 2-图片模式
way: number // 是 生成模式 1-自定义(模型) 2-模板
qr_content: string // 否 二维码内容 (文本模式时必填)
qr_image: string // 否 二维码图片 (图片模式时必填)
prompt: string // 否 关键词
prompt_params: PromptParams | string // 否 其他参数
model_id: string | number // 否 模型id
template_name: string // 否 模板名称
template_id: string | number // 否 模板id
aspect_ratio: string | number // 否 比例 (知数云)
pixel_style: string | number // 否 码点形状(知数云)
marker_shape: string | number // 否 码眼选择(知数云)
}
export type PromptParams = {
v: string // 版本取值枚举 2 1.1 1) 示例:--v 2 --v 1.1
iw: number // (明显程序取值范围 0 - 1, 保留两位小数) 示例: --iw 0.45
seed: string // (取值范围1 - 999999999 ) 示例: --seed 123
shape: string // (码眼选择范围) ["square", "circle", "plus", "box", "octagon", "random", "tiny-plus"], 示例 --shape random ,默认为 random
ar: string // (尺寸选择) 范围 ["1:1", "9:16", "16:9", "3:4","4:3"] 示例 --ar 1:1 ,默认为 1:1
}
// 获取艺术二维码配置
export function qrcodeConfig() {
return request.get({ url: '/qrcode/config' })
}
// 生成艺术二维码
export function qrcodeImagine(data: QrcodeFormType) {
return request.post({ url: '/qrcode/execute', data, method: 'POST' })
}
// 生成艺术二维码详情
export function qrcodeDetail(data: { records_id: number[] }) {
return request.post({ url: '/qrcode/records/detail', data })
}
// 生成艺术二维码记录
export function qrcodeRecord(data: any) {
return request.get(
{
url: '/qrcode/records/list',
data
},
{ ignoreCancel: true }
)
}
// 删除
export function qrcodeDelete(data: number[]) {
return request.post({
url: '/qrcode/records/del',
data,
method: 'POST'
})
}

21
src/api/recharge.ts Normal file
View File

@ -0,0 +1,21 @@
import request from '@/utils/request'
//充值
export function recharge(data: any) {
return request.post({ url: '/recharge/placeOrder', data }, { isAuth: true })
}
//充值记录
export function rechargeRecord(data: any) {
return request.get({ url: '/recharge/record', data }, { isAuth: true })
}
// 充值配置
export function rechargeConfig() {
return request.get({ url: '/recharge/config' }, { isAuth: true })
}
// 充值配置
export function getRechargeConfig() {
return request.get({ url: '/recharge/rechargePackage' }, { isAuth: true })
}

1
src/api/redeem_code.ts Normal file
View File

@ -0,0 +1 @@
import request from '@/utils/request' export type RedeemCodeResponse = { content: string // 卡密内容 failure_time: string // 失效时间 id: string | number // 卡密ID sn: string | number // 卡密编号 type: string // 卡密类型 type_desc: string // 卡密类型说明 } /** * @description 卡密查询 * @return { RedeemCodeResponse } * @param data */ export function checkRedeemCode(data: { sn: number | string }): Promise<RedeemCodeResponse> { return request.get({ url: '/card_code/record/get', data: data }) } /** * @description 卡密兑换 * @param data */ export function useRedeemCode(data: { sn: number | string }) { return request.post({ url: '/card_code/record/use', data: data }) }

16
src/api/shop.ts Normal file
View File

@ -0,0 +1,16 @@
import { client } from '@/utils/client'
import request from '@/utils/request'
//首页数据
export function getIndex() {
return request.get({ url: '/index/index' })
}
// 装修页面
export function getDecorate(data: any) {
return request.get({ url: '/index/decorate', data }, { ignoreCancel: true })
}
export function addVisit() {
return request.post({ url: '/index/visit/add', data: { terminal: client } })
}

1
src/api/square.ts Normal file
View File

@ -0,0 +1 @@
import request from '@/utils/request' /** * @description 绘画广场列表 * @return { Promise } * @param params */ export function getDrawSquareLists(data?: any) { return request.get({ url: '/draw/square/list', data }) } /** * @description 绘画广场列表 * @return { Promise } */ export function getDrawSquareCateLists() { return request.get({ url: '/draw/square/category/list' }) } /** * @description 分享至绘画广场 * @return { Promise } */ export function shareDrawSquare(data: any) { return request.post({ url: '/draw/square/add', data }) } /** * @description 喜欢绘画 * @return { Promise } */ export function collectDraw(data: any) { return request.post({ url: '/draw/square/collect', data }) } /** * @description 取消喜欢绘画 * @return { Promise } */ export function cancelCollectDraw(data: any) { return request.post({ url: '/draw/square/cancelCollect', data }) }

25
src/api/task.ts Normal file
View File

@ -0,0 +1,25 @@
import request from '@/utils/request'
export function getShareId() {
return request.post({ url: '/share/share' })
}
export function shareClick(data: any) {
return request.post({ url: '/share/click', data })
}
export function bindInvite(data: any, token: string) {
return request.post({ url: '/share/invite', data, header: { token } })
}
export function bindRegisterInvite(data: any) {
return request.post({ url: '/share/register/invite', data })
}
export function getTask() {
return request.get({ url: '/rewards/task' })
}
export function signClick() {
return request.post({ url: '/sign/click' })
}

58
src/api/user.ts Normal file
View File

@ -0,0 +1,58 @@
import request from '@/utils/request'
export function getUserCenter(header?: any) {
return request.get({ url: '/user/center', header })
}
// 个人信息
export function getUserInfo() {
return request.get({ url: '/user/info' }, { isAuth: true })
}
// 个人编辑
export function userEdit(data: any) {
return request.post({ url: '/user/edit', data }, { isAuth: true })
}
// 绑定手机
export function userBindMobile(data: any, header?: any) {
return request.post({ url: '/login/bindMobile', data, header }, { isAuth: true })
}
// 微信电话
export function userMnpMobile(data: any, header?: any) {
return request.post({ url: '/user/mnpMobile', data, header }, { isAuth: true })
}
export function userChangePwd(data: any) {
return request.post({ url: '/user/changePwd', data }, { isAuth: true })
}
// 绑定小程序
export function mnpAuthBind(data: any) {
return request.post({ url: '/user/bindMnp', data })
}
// 绑定公众号
export function oaAuthBind(data: any) {
return request.post({ url: '/user/bindOa', data })
}
//更新微信小程序头像昵称
export function updateUser(data: Record<string, any>, header: any) {
return request.post({ url: '/user/updateUser', data, header })
}
//余额明细
export function accountLog(data: any) {
return request.get({ url: '/logs/userMoney', data })
}
//一键反馈
export function feedbackPost(data: any) {
return request.post({ url: '/feedback/add', data })
}
//注销账号
export function cancelled(data?: any) {
return request.post({ url: '/login/cancelled', data })
}

View File

@ -0,0 +1 @@
<template> <view class="agreement" v-if="isOpenAgreement" :class="{ shake: isShake }"> <view> <u-checkbox v-model="isActive" shape="circle"> <view class="text-xs flex"> 已阅读并同意 <view @click.stop> <router-navigate class="text-primary" to="/packages/pages/agreement/agreement?type=service" > 《服务协议》 </router-navigate> </view> <view @click.stop> <router-navigate class="text-primary" to="/packages/pages/agreement/agreement?type=privacy" > 《隐私协议》 </router-navigate> </view> </view> </u-checkbox> </view> </view> </template> <script lang="ts" setup> import { useAppStore } from '@/stores/app' import { computed, ref } from 'vue' const appStore = useAppStore() const isActive = ref(false) const isShake = ref(false) const isOpenAgreement = computed( () => appStore.getLoginConfig.openAgreement == 1 ) const checkAgreement = () => { if (!isActive.value && isOpenAgreement.value) { uni.$u.toast('请勾选已阅读并同意《服务协议》和《隐私协议》') isShake.value = true setTimeout(() => { isShake.value = false }, 1000) } else if (!isOpenAgreement.value) { return true } return isActive.value } defineExpose({ checkAgreement }) </script> <style lang="scss"> .shake { animation: shake 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both; transform: translate3d(0, 0, 0); } @keyframes shake { 10%, 90% { transform: translate3d(-1px, 0, 0); } 20%, 80% { transform: translate3d(2px, 0, 0); } 30%, 50%, 70% { transform: translate3d(-4px, 0, 0); } 40%, 60% { transform: translate3d(4px, 0, 0); } } </style>

View File

@ -0,0 +1,135 @@
<template>
<view class="audio" @click="togglePlay" :class="{ reverse }">
<view
:style="{
transform: `rotate(${reverse ? '135deg' : '-45deg'})`
}"
>
<view
class="wifi-symbol"
:class="{
playing: isPlaying
}"
>
<view class="wifi-circle first"></view>
<view class="wifi-circle second"></view>
<view class="wifi-circle third"></view>
</view>
</view>
<view class="duration">{{
duration ? Math.round(duration) + '"' : ''
}}</view>
</view>
</template>
<script setup lang="ts">
import { ref, shallowRef, watch } from 'vue'
import { useAudio } from '@/hooks/useAudio'
const props = withDefaults(
defineProps<{
url: string
color?: string
bgColor?: string
reverse?: boolean
}>(),
{
color: '#000',
bgColor: '#fff',
reverse: false
}
)
const { pause, play, duration, isPlaying, setUrl } = useAudio()
const togglePlay = () => {
if (isPlaying.value) {
pause()
} else {
play()
}
}
watch(
() => props.url,
(value) => {
if (value) {
setUrl(value)
}
},
{
immediate: true
}
)
</script>
<style lang="scss" scoped>
.audio {
padding: 0 20rpx;
background-color: v-bind(bgColor);
display: inline-flex;
height: 58rpx;
align-items: center;
border-radius: 100rpx;
min-width: 120rpx;
&.reverse {
flex-direction: row-reverse;
// . {
// transform: rotateZ(135deg);
// }
}
.wifi-symbol {
margin: 10rpx;
position: relative;
width: 30rpx;
height: 30rpx;
box-sizing: border-box;
overflow: hidden;
z-index: 999;
}
.wifi-circle {
border: 4rpx solid v-bind(color);
border-radius: 50%;
position: absolute;
left: 0;
top: 0;
transform: scale(0.9) rotate(45deg) translateX(-50%);
transform-origin: center center;
}
.first {
width: 6rpx;
height: 6rpx;
background: v-bind(color);
opacity: 1;
}
.second {
width: 25rpx;
height: 25rpx;
}
.third {
width: 40rpx;
height: 40rpx;
}
.duration {
color: v-bind(color);
}
.playing {
.second {
opacity: 0;
animation: fadeInOut 1s infinite 0.2s;
}
.third {
opacity: 0;
animation: fadeInOut 1s infinite 0.4s;
}
}
@keyframes fadeInOut {
0% {
opacity: 0; /*初始状态 透明度为0*/
}
100% {
opacity: 1; /*结尾状态 透明度为1*/
}
}
}
</style>

View File

@ -0,0 +1,111 @@
<template>
<button
class="avatar-upload p-0 m-0 rounded inline-flex flex-col items-center"
hover-class="none"
open-type="chooseAvatar"
@click="chooseAvatar"
@chooseavatar="chooseAvatar"
>
<image
:style="styles"
class="w-full h-full"
mode="heightFix"
:src="modelValue"
v-if="modelValue"
/>
<slot v-else>
<view
:style="styles"
class="border border-dotted border-light flex w-full h-full flex-col items-center justify-center text-muted text-xs box-border rounded"
>
<u-icon name="plus" :size="36" />
添加图片
</view>
</slot>
<slot name="footer"></slot>
</button>
</template>
<script lang="ts" setup>
import { addUnit } from '@/utils/util'
import { isBoolean } from 'lodash'
import { computed, CSSProperties, onUnmounted } from 'vue'
import { useRouter } from 'uniapp-router-next'
import { client } from '@/utils/client'
import { usePermissionsStore } from '@/stores/androidPermissions'
import { ClientEnum } from '@/enums/appEnums'
const router = useRouter()
const props = defineProps({
modelValue: {
type: String
},
size: {
type: [String, Number],
default: 140
},
round: {
type: [Boolean, String, Number],
default: false
},
border: {
type: Boolean,
default: true
}
})
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
(event: 'upload', value: string): void
}>()
const styles = computed<CSSProperties>(() => {
const size = addUnit(props.size)
return {
width: size,
height: size,
borderRadius: isBoolean(props.round) ? (props.round ? '50%' : '') : addUnit(props.round)
}
})
const chooseAvatar = async (e: any) => {
// #ifdef APP-PLUS
if (client == ClientEnum['ANDROID']) {
const { requestPermissions } = usePermissionsStore()
const result = await requestPermissions('WRITE_EXTERNAL_STORAGE')
if (result !== 1) return
}
// #endif
// #ifndef MP-WEIXIN
router.navigateTo({
path: '/uni_modules/vk-uview-ui/components/u-avatar-cropper/u-avatar-cropper?destWidth=300&rectWidth=200&fileType=jpg'
})
// #endif
// #ifdef MP-WEIXIN
const path = e.detail?.avatarUrl
if (path) {
uploadImageIng(path)
}
// #endif
}
const uploadImageIng = async (file: string) => {
emit('update:modelValue', file)
emit('upload', file)
}
//
uni.$on('uAvatarCropper', (path) => {
// console.log(path)
uploadImageIng(path)
})
onUnmounted(() => {
uni.$off('uAvatarCropper')
})
</script>
<style lang="scss" scoped>
.avatar-upload {
background: #fff;
overflow: hidden;
&::after {
border: none;
}
}
</style>

View File

@ -0,0 +1,166 @@
<template>
<view class="chat-plugins mr-2" v-if="plugins.length">
<view
class="flex items-center"
:class="{ 'text-primary': show }"
@click.stop="togglePopup"
>
<image
src="@/static/images/chat_plugins/plugins.png"
class="w-[30rpx] h-[30rpx] flex-none"
/>
<text class="ml-1 line-clamp-1">
{{
currentPlugin.key ? currentPlugin.name : '插件管理'
}}
</text>
</view>
<u-popup
v-model="show"
mode="bottom"
height="900"
:closeable="true"
border-radius="20"
closeIconColor="#5b5b5b"
closeIconSize="28"
>
<view
class="text-xl font-medium text-black py-[26rpx] mx-[30rpx]"
style="border-bottom: 1px solid #f3f3f3"
>
插件管理
</view>
<view class="px-[30rpx] pt-[30rpx]">
<view
v-for="item in plugins"
:key="item.key"
class="flex items-center justify-between mb-[30rpx] rounded-lg bg-page-base p-[30rpx] border border-solid border-page-base"
:class="{
'bg-primary-light-9 !border-primary': item.key === currentId
}"
@click="handleClick(item)"
>
<view>
<view class="flex items-center">
<image
:src="item.icon"
class="w-[34rpx] h-[34rpx] mr-2 flex-none"
/>
<text class="text-base">{{ item.name }}</text>
</view>
</view>
<view
v-if="item.key === currentId"
class="text-primary"
>
<u-icon
name="checkmark-circle-fill"
size="40rpx"
></u-icon>
</view>
<image
v-else
:src="iconUnSelect"
class="w-[32rpx] h-[32rpx] mr-[2px] flex-none"
/>
</view>
</view>
</u-popup>
</view>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useVModel } from '@vueuse/core'
import cache from '@/utils/cache'
// import { PLUGIN_MODEL_KEY } from '@/enums/constantEnums'
// icon
import iconUnUse from '@/static/images/chat_plugins/unuse.png'
import iconUnSelect from '@/static/images/icon/icon_unselect.png'
// com
import { getPlugLists } from '@/api/chat'
import { onShow } from '@dcloudio/uni-app'
const props = withDefaults(
defineProps<{
modelValue: number | string
current?: Record<string, any>
}>(),
{
modelValue: '',
current: () => ({})
}
)
const emit = defineEmits<{
(event: 'update:modelValue', id: number | string): void
(event: 'update:current', value: any): void
(event: 'change', value: any): void
}>()
const show = ref<boolean>(false)
const plugins = ref<any[]>([])
const currentId = useVModel(props, 'modelValue', emit)
const currentPlugin = computed(() => {
return plugins.value.find((item) => item.key === currentId.value) || {}
})
const togglePopup = () => {
show.value = !show.value
}
const handleClick = (row: { key: string }) => {
currentId.value = row.key
togglePopup()
}
const getData = async () => {
const data = await getPlugLists()
if (data.length) {
plugins.value = [
{
name: '不使用插件',
key: '',
icon: iconUnUse,
balance: 0
},
...data
]
// const pluginId = cache.get(PLUGIN_MODEL_KEY) || ''
// const res = data.filter((item: any) => item.key == pluginId.value)
// if (res.length) {
// currentId.value = pluginId.value || ''
// }
}
}
onShow(() => {
getData()
})
watch(currentId, (value) => {
emit('change', value)
emit('update:current', currentPlugin.value)
})
</script>
<style lang="scss" scoped>
.chat-plugins {
position: relative;
flex: 1;
width: auto;
&__content {
inset: auto auto 130% 0%;
position: absolute;
left: 0;
flex: 1;
width: 100vw;
white-space: pre;
z-index: 999;
@apply bg-white;
}
}
</style>

View File

@ -0,0 +1,61 @@
<template>
<view
class="px-[20rpx] h-[90rpx] text-sm flex items-center justify-between bg-white"
>
<view class="flex">
<u-button
type="primary"
plain
size="medium"
:custom-style="{
background: 'transparent !important'
}"
v-if="appStore.getIsShowVip"
>
<router-navigate
class="text-primary"
to="/packages/pages/open_vip/open_vip"
>
{{
userInfo.is_member && userInfo.member_expired !== 1
? userInfo.member_package_name
: '开通会员'
}}
</router-navigate>
</u-button>
</view>
<view>
<view v-if="plugin.key" class="text-muted mr-2 text-xs">
<text v-if="!plugin.member_free">
<template v-if="plugin.balance > 0">
<text>消耗</text>
<text class="text-primary">
{{ plugin.balance }}
</text>
<text>条对话次数</text>
</template>
<template v-else> 免费 </template>
</text>
<text v-else> 会员免费 </text>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { useAppStore } from '@/stores/app'
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'
const props = withDefaults(
defineProps<{
plugin: Record<string, any>
}>(),
{
plugin: () => ({})
}
)
const userStore = useUserStore()
const appStore = useAppStore()
const { userInfo } = storeToRefs(userStore)
</script>

View File

@ -0,0 +1,305 @@
<template>
<view class="chat-record-item">
<view :class="`chat-record-item__${type}`">
<view>
<u-icon
class="rounded-full overflow-hidden"
:name="type == 'left' ? avatar : userStore.userInfo.avatar"
:size="60"
/>
</view>
<view
class="min-w-0 flex flex-col"
:class="{ 'justify-end': type == 'right' }"
>
<div v-if="time" class="ml-[25rpx] mb-[20rpx] text-muted">
{{ time }}
<u-tag
v-if="modelName"
class="ml-2"
size="mini"
:text="modelName"
style="
--color-success: #67c23a;
--color-success-light-3: transparent;
--color-success-light-9: #f0f9eb;
"
type="success"
/>
</div>
<view :class="`chat-record-item__${type}-content`">
<!-- <slot name="content-header"> </slot> -->
<template v-if="file.type">
<RecordImage
v-if="file.type == 1"
:url="file.url"
:name="file.name"
/>
<RecordFile
v-else-if="file.type == 2"
:url="file.url"
:name="file.name"
/>
</template>
<view>
<view
class="mb-[20rpx] flex"
:class="{
'justify-end': type === 'right'
}"
v-if="audio"
>
<audio-play
:url="audio"
:reverse="type === 'right'"
:bg-color="'#fff'"
></audio-play>
</view>
<template v-if="isArray(content)">
<view
v-for="(item, index) in content"
:key="index"
class="mb-[20rpx] last-of-type:mb-0"
:class="{
'pt-[20rpx] border-t border-solid border-light border-0':
index > 0
}"
>
<text-item
:is-markdown="isMarkdown"
:content="item"
:loading="loading"
:index="index"
:record-id="recordId"
:show-copy-btn="
showCopyBtn && type === 'left'
"
:show-voice-btn="appStore.getIsVoiceOpen"
/>
</view>
</template>
<template v-else>
<text-item
:is-markdown="isMarkdown"
:content="content"
:loading="loading"
:show-copy-btn="showCopyBtn && type === 'left'"
/>
</template>
<view
class="flex items-center text-muted text-sm mt-[16rpx]"
v-if="loading"
>
<u-loading mode="flower"></u-loading>
<view class="ml-[10rpx]">加载中请稍等</view>
</view>
</view>
</view>
<view
v-if="type == 'right'"
class="flex items-center justify-end pr-[20rpx] pt-[10rpx]"
@click="copy(content)"
>
<image
class="w-[26rpx] h-[26rpx]"
src="@/static/images/icon/icon_copy.png"
></image>
<text class="text-xs text-muted ml-[8rpx]">复制</text>
</view>
<slot name="footer"></slot>
<view v-if="!loading && type === 'left'">
<view class="my-[16rpx] flex justify-end text-muted">
<view
v-if="showRewriteBtn"
class="text-xs flex items-center rounded-full ml-[20rpx]"
@click="emit('rewrite')"
>
<u-icon
name="reload"
class="mr-[8rpx]"
:size="30"
/>
重写
</view>
<view
v-if="showCollectBtn && recordId"
class="text-xs flex items-center rounded-full ml-[20rpx]"
:class="{
'text-primary': isCollect,
'text-muted': !isCollect
}"
@click="handleCollect(recordId)"
>
<u-icon
:name="isCollect ? 'star-fill' : 'star'"
class="mr-[8rpx]"
:size="30"
/>
<text class="text-muted">收藏</text>
</view>
<view
v-if="showPosterBtn && recordId"
class="text-xs flex items-center rounded-full ml-[20rpx]"
@click="emit('click-poster', recordId)"
>
<u-icon
name="photo"
class="mr-[8rpx]"
:size="30"
></u-icon>
生成海报
</view>
</view>
</view>
<!-- 生成海报 -->
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { cancelCollectChatRecord, collectChatRecord } from '@/api/chat'
import { useCopy } from '@/hooks/useCopy'
import { useLockFn } from '@/hooks/useLockFn'
import { useUserStore } from '@/stores/user'
import { useAppStore } from '@/stores/app'
import TextItem from './text-item.vue'
import { isArray } from 'lodash-es'
import { computed } from 'vue'
import RecordImage from './record-image.vue'
import RecordFile from './record-file.vue'
// import
const props = withDefaults(
defineProps<{
recordId?: number | string
type: 'left' | 'right'
content: string
showCopyBtn?: boolean
showCollectBtn?: boolean
showRewriteBtn?: boolean
showPosterBtn?: boolean
loading?: boolean
index?: number
isCollect?: number
audio?: string
modelName?: string
avatar: string
time?: string
file?: Record<string, any>
}>(),
{
showCollectBtn: true,
showCopyBtn: true,
showRewriteBtn: false,
showPosterBtn: false,
content: '',
loading: false,
modelName: '',
time: '',
file: () => ({})
}
)
const emit = defineEmits<{
(event: 'close'): void
(event: 'rewrite'): void
(event: 'update', value: any): void
(event: 'click-poster', value?: number | string): void
}>()
const userStore = useUserStore()
const appStore = useAppStore()
const { copy } = useCopy()
const { lockFn: handleCollect } = useLockFn(async (id: number | string) => {
if (props.isCollect) {
await cancelCollectChatRecord({
id: id
})
uni.$u.toast('取消收藏')
emit('update', { index: props.index, value: 0 })
} else {
await collectChatRecord({
id: id
})
uni.$u.toast('收藏成功')
emit('update', { index: props.index, value: 1 })
}
})
const isMarkdown = computed(() => {
return appStore.getChatConfig.isMarkdown && props.type == 'left'
})
</script>
<style lang="scss" scoped>
@keyframes typingFade {
0% {
opacity: 0;
}
50% {
opacity: 100%;
}
100% {
opacity: 100%;
}
}
.chat-record-item {
padding: 0 20rpx;
margin-bottom: 40rpx;
&__left,
&__right {
display: flex;
align-items: flex-start;
min-height: 80rpx;
&-content {
display: inline-block;
padding: 20rpx;
max-width: 100%;
border-radius: 10rpx;
position: relative;
min-width: 70rpx;
min-height: 80rpx;
&::before {
content: '';
display: block;
width: 0;
height: 0;
position: absolute;
top: 24rpx;
border: 16rpx solid transparent;
}
}
.text-typing {
display: inline-block;
vertical-align: -8rpx;
height: 34rpx;
width: 6rpx;
background-color: $u-type-primary;
animation: typingFade 0.4s infinite alternate;
}
}
&__right {
flex-direction: row-reverse;
}
&__left-content {
margin-left: 25rpx;
background-color: $u-bg-color;
&::before {
left: -30rpx;
border-right-color: $u-bg-color;
}
}
&__right-content {
color: #fff;
background-color: #4073fa;
margin-right: 20rpx;
&::before {
right: -30rpx;
border-left-color: #4073fa;
}
}
}
</style>

View File

@ -0,0 +1,49 @@
<template>
<view
v-if="url"
class="flex mb-[12rpx] bg-white rounded-[10rpx] p-[20rpx] max-w-[450rpx] items-center"
@click="onPreview"
>
<u-image class="flex-none" :src="icon_doc" width="80" height="80" />
<view
class="line-clamp-2 text-main flex-1 min-w-0 ml-[12rpx]"
:style="{
'word-break': 'break-word'
}"
>
{{ name }}
</view>
</view>
</template>
<script lang="ts" setup>
import icon_doc from '@/static/images/icon/icon_doc.png'
const props = defineProps<{
url: string
name: string
}>()
const onPreview = async () => {
//#ifdef H5
window.open(props.url, '_blank')
//#endif
//#ifndef H5
try {
uni.showLoading({
title: '请稍等...'
})
const { tempFilePath } = await uni.downloadFile({
url: props.url
})
await uni.openDocument({
filePath: tempFilePath,
showMenu: true
})
uni.hideLoading()
} catch (error) {
uni.hideLoading()
uni.$u.toast(`文件${props.name}打开失败,请重试`)
}
//#endif
}
</script>

View File

@ -0,0 +1,34 @@
<template>
<view
v-if="url"
class="flex mb-[12rpx] bg-white rounded-[10rpx] p-[20rpx] max-w-[450rpx] items-center"
>
<u-image
class="flex-none"
:src="url"
width="80"
height="80"
@click="onPreview([url])"
/>
<view
class="line-clamp-2 text-main flex-1 min-w-0 ml-[12rpx]"
:style="{
'word-break': 'break-word'
}"
>
{{ name }}
</view>
</view>
</template>
<script lang="ts" setup>
const props = defineProps<{
url: string
name: string
}>()
const onPreview = (picture: string[]) => {
uni.previewImage({
urls: picture
})
}
</script>

View File

@ -0,0 +1,91 @@
<template>
<template v-if="isMarkdown">
<ua-markdown :content="content"></ua-markdown>
</template>
<template v-else>
<text
user-select
class="whitespace-pre-line leading-[40rpx] select-text"
>
{{ content }}
</text>
</template>
<view class="flex items-center" v-if="!loading">
<view
v-if="showCopyBtn"
class="text-content text-sm flex items-center mr-[20rpx] mt-[16rpx]"
@click="copy(content)"
>
<image
class="w-[26rpx] h-[26rpx] mr-[8rpx]"
src="@/static/images/common/icon_copy.png"
alt="复制"
/>
复制
</view>
<template v-if="showVoiceBtn">
<view
v-if="!audioPlaying"
class="text-content text-sm flex items-center mt-[16rpx]"
@click="play"
>
<u-loading
v-if="audioLoading"
mode="flower"
class="mr-[8rpx]"
:size="26"
></u-loading>
<u-icon v-else name="volume" class="mr-[8rpx]" />
朗读
</view>
<view
v-else
class="text-content text-sm flex items-center mt-[16rpx]"
@click="pause"
>
<u-icon name="volume" class="mr-[8rpx]" />
停止
</view>
</template>
</view>
</template>
<script lang="ts">
export default {
options: {
virtualHost: true
},
externalClasses: ['class']
}
</script>
<script setup lang="ts">
import { getChatBroadcast } from '@/api/chat'
import { useAudioPlay } from '@/hooks/useAudioPlay'
import { useCopy } from '@/hooks/useCopy'
const props = withDefaults(
defineProps<{
content: string
isMarkdown: boolean
loading?: boolean
showCopyBtn?: boolean
showVoiceBtn?: boolean
recordId?: number | string
index?: number
}>(),
{
showCopyBtn: false,
loading: false,
showVoiceBtn: false
}
)
const { copy } = useCopy()
const { play, audioPlaying, pause, audioLoading } = useAudioPlay({
api: getChatBroadcast,
dataTransform(data) {
return data.path
},
params: {
text: props.content.toString()
}
})
</script>

View File

@ -0,0 +1,865 @@
<template>
<view class="chat-scroll-view h-full flex flex-col">
<view class="flex-1 min-h-0">
<z-paging
ref="pagingRef"
v-model="chatList"
use-chat-record-mode
:auto="false"
:refresher-enabled="false"
:safe-area-inset-bottom="true"
:auto-clean-list-when-reload="false"
:show-chat-loading-when-reload="true"
:paging-style="{ bottom: keyboardIsShow ? 0 : bottom }"
:default-page-size="20"
@query="queryList"
@keyboardHeightChange="keyboardHeightChange"
@hidedKeyboard="hidedKeyboard"
>
<!-- 顶部提示文字 -->
<!-- style="transform: scaleY(-1)"必须写否则会导致列表倒置必须写在for循环标签上不得写在容器上 -->
<!-- 注意不要直接在chat-item组件标签上设置style因为在微信小程序中是无效的请包一层view -->
<template #top>
<slot name="top" />
<model-picker
v-if="!pluginOptions.pluginId"
v-model:chatKey="chatKey"
v-model:modelKey="modelKey"
/>
<VipUse
v-if="pluginOptions.pluginId"
:plugin="pluginOptions.current"
/>
</template>
<view class="scroll-view-content pb-[20rpx]" ref="contentRef">
<view
v-for="(item, index) in chatList"
:key="`${item.id} + ${index} + ''`"
style="transform: scaleY(-1)"
>
<view class="chat-record mt-[20rpx] pb-[40rpx]">
<chat-record-item
:record-id="item.id"
:type="item.type == 1 ? 'right' : 'left'"
:content="item.content"
:loading="item.loading"
:audio="item.voiceFile"
:index="index"
:time="item.type == 2 ? item.createTime : ''"
:model-name="appStore.getChatConfig.show_model ? item.model : ''"
:is-collect="item.is_collect"
:avatar="avatar"
:showRewriteBtn="index === 0"
:showPosterBtn="true"
:showCopyBtn="true"
:file="{
url: item.fileUrl,
name: item.fileName,
type: item.fileType
}"
@rewrite="rewrite(index)"
@update="(e: any) => chatList[e.index].is_collect = e.value"
@click-poster="handleDrawPoster"
>
</chat-record-item>
</view>
</view>
</view>
<template #empty>
<slot name="empty" />
</template>
<template #bottom>
<view class="send-area">
<view class="float-btn">
<view
v-if="chatList.length && !isReceiving"
class="px-[20rpx] py-[10rpx] text-xs flex items-center"
@click="sendLock('继续')"
>
<u-icon name="play-circle" class="mr-[8rpx]" size="36" />
继续
</view>
<view
v-if="isReceiving"
class="px-[20rpx] py-[10rpx] text-xs flex items-center"
@click="chatClose()"
>
<u-icon name="pause-circle" class="mr-[8rpx]" size="36" />
停止
</view>
</view>
<view class="mb-[20rpx]" v-if="isShowFileUpload">
<file-upload
v-model="pluginOptions.file"
return-type="object"
:file-type="pluginOptions.type"
:file-extname="getFileExtname"
:data="{
type:
pluginOptions.type == 'file'
? 'docs'
: '',
key: pluginOptions.type != 'image' ? pluginOptions.pluginId : ''
}"
/>
</view>
<view class="mb-[20rpx] flex items-center">
<view class="flex items-center mr-auto">
<chat-plugins
v-if="type === 1"
v-model="pluginOptions.pluginId"
v-model:current="pluginOptions.current"
@change="pluginChange"
>
</chat-plugins>
<network-switch
v-if="!pluginOptions.pluginId"
v-model="network"
></network-switch>
</view>
<view class="flex text-content items-center flex-none">
<view class="text-xs flex items-center" @click="cleanChatLock">
<u-icon name="trash" class="mr-[4rpx]" size="28" />
清空
</view>
</view>
</view>
<view
class="send-area__content bg-page-base"
:class="[safeAreaInsetBottom ? 'safe-area-inset-bottom' : '']"
>
<view class="flex-1 min-w-0 relative">
<view
v-if="showPressBtn"
class="absolute left-[-10rpx] top-[-15rpx] bottom-[-15rpx] bg-primary text-btn-text right-[0] z-[9999] flex items-center justify-center rounded-[12rpx]"
@longpress="handleLongpress"
@touchend="touchEnd"
@touchcancel="touchEnd"
>
按住说话
</view>
<u-input
type="textarea"
v-model="userInput"
:placeholder="placeholder"
maxlength="-1"
:auto-height="true"
confirm-type="send"
:adjust-position="false"
:fixed="false"
adjust-keyboard-to="bottom"
@click="handleClick"
@focus="scrollToBottom"
/>
</view>
<view class="ml-[20rpx] my-[-12rpx]">
<view v-if="userInput">
<u-button
type="primary"
:custom-style="{
width: '100rpx',
height: '52rpx',
margin: '0'
}"
size="mini"
:disabled="isReceiving"
@click.stop="sendLock()"
>
发送
</u-button>
</view>
<view v-else-if="appStore.getIsVoiceTransfer">
<view
v-if="showPressBtn"
class="text-content"
@click="triggerRecordShow"
>
<u-icon name="more-circle" :size="52" />
</view>
<view v-else class="text-content" @click="triggerRecordShow">
<u-icon name="mic" :size="52" />
</view>
</view>
</view>
</view>
</view>
<view v-if="type == 1 && appStore.getIsVoiceChat">
<dragon-button :size="184" :yEdge="160">
<view class="p-[20rpx]" @click="triggerVoiceShow">
<view class="flex justify-center mb-[-20rpx] relative z-[99]">
<image
class="w-[70rpx] h-[70rpx] mb-[10rpx]"
:src="loadingPath"
/>
</view>
<view>
<u-button
hover-class="none"
:custom-style="{
width: '100%',
height: '58rpx',
fontSize: '24rpx',
background: '#28C840',
'box-shadow': '0 3px 10px #00000033'
}"
type="primary"
shape="circle"
>
在线语音
</u-button>
</view>
</view>
</dragon-button>
</view>
</template>
</z-paging>
</view>
<guided-popup ref="guidedPopupRef" />
<!--#ifdef APP-PLUS-->
<appChat
@onmessage="appOnmessage"
@onclose="appOnclose"
@onstart="appOnstart"
ref="appChatRef"
></appChat>
<!--#endif-->
<recorder ref="recorderRef" v-model:show="showRecorder" @success="sendLock" />
</view>
<online-voice
v-model:show="showOnlineVoice"
:data="{
chatKey: chatKey,
modelKey: modelKey,
type: type,
categoryId: otherId,
network: network
}"
@update="pagingRef?.reload()"
/>
<!-- 生产对话海报 -->
<dialog-poster ref="posterRef"></dialog-poster>
</template>
<script lang="ts">
export default {
options: {
virtualHost: true,
styleIsolation: 'shared'
}
}
</script>
<script lang="ts" setup>
import { chatSendText, cleanChatRecord, getChatRecord, delChatRecord } from '@/api/chat'
import { useLockFn } from '@/hooks/useLockFn'
import { useUserStore } from '@/stores/user'
import { useAppStore } from '@/stores/app'
import { useRouter } from 'uniapp-router-next'
import { onUnmounted, watch, ref, shallowRef, reactive, computed } from 'vue'
import { onHide, onShow } from '@dcloudio/uni-app'
import { useSessionList } from '@/pages/index/components/useSessionList'
import { RequestErrMsgEnum } from '@/enums/requestEnums'
import { useAudioPlay } from '@/hooks/useAudioPlay'
import { CHAT_LIMIT_KEY } from '@/enums/constantEnums'
import { isNewDay } from '@/utils/validate'
import { useRecorder } from '@/hooks/useRecorder'
import OnlineVoice from './components/online-voice.vue'
import { useAudio } from '@/hooks/useAudio'
import { vibrate } from '@/utils/device/vibrate'
// import cache from '@/utils/cache'
import config from '@/config'
import VipUse from '../chat-plugins/vip-use.vue'
import ModelPicker from '../model-picker/model-picker.vue'
//#ifdef APP-PLUS
import appChat from './components/app-chat'
const appChatRef = shallowRef()
//#endif
const loadingUrl = '/api/static/bubble.gif'
let loadingPath = `${config.baseUrl}${loadingUrl}`
//#ifdef H5
loadingPath = `${config.baseUrl === '/' ? `${location.origin}/` : config.baseUrl}${loadingUrl}`
//#endif
const emit = defineEmits<{
(event: 'update:modelValue', value: any[]): void
(event: 'reader', value: any): void
}>()
const props = withDefaults(
defineProps<{
type: number
otherId?: number
tips?: string
bottom?: string
placeholder?: string
safeAreaInsetBottom: boolean
showAdd?: boolean
avatar?: string
}>(),
{
tips: '',
placeholder: '请输入内容',
safeAreaInsetBottom: false,
showAdd: false,
bottom: '0'
}
)
const chatKey = ref('')
const modelKey = ref('')
const appStore = useAppStore()
const router = useRouter()
const userStore = useUserStore()
const pagingRef = shallowRef()
const contentRef = shallowRef()
const guidedPopupRef = shallowRef()
const network = ref(false)
const showPressBtn = ref(false)
const showRecorder = ref(false)
const showOnlineVoice = ref(false)
const { sessionActive, sessionAdd, currentSession, sessionEdit } = useSessionList()
const chatList = ref<any[]>([])
const pluginOptions = reactive({
type: 'file',
file: { url: '' } as any,
pluginId: '',
current: {}
})
const userInput = ref('')
const newUserInput = ref('')
const { authorize } = useRecorder({})
const { pauseAll } = useAudio()
const getFileExtname = computed(() => {
switch (pluginOptions.type) {
case 'image':
return ['jpg', 'png', 'gif', 'jpeg']
default:
return ['doc', 'docx', 'pdf', 'md', 'txt']
}
})
const isShowFileUpload = computed(() => {
return (
pluginOptions.pluginId === 'xinghuo-chatdoc' ||
pluginOptions.pluginId === 'gpt-4-all' ||
pluginOptions.pluginId === 'kimi-chatdoc'
)
})
const handleClick = () => {
if (!userStore.isLogin) {
return toLogin()
}
}
// action --
const actions = ref('')
const queryList = async (pageNo: number, pageSize: number) => {
try {
// { lists = [], count }
const lists = await getChatRecord({
type: props.type,
action: actions.value,
otherId: props.type !== 1 ? props.otherId : '',
categoryId: props.type === 1 ? props.otherId : '',
// pageSize: pageSize / 2,
// pageNo: pageNo
})
// pagingRef.value?.complete(lists.reverse())
// pagingRef.value?.complete(lists)
chatList.value = lists.reverse()
if (lists.length === 0) {
setTimeout(() => {
pagingRef.value?.scrollToTop(false)
}, 200)
} else {
setTimeout(() => {
scrollToBottom()
actions.value = 'stop'
}, 100)
}
if (!lists.length) {
pagingRef.value?.complete([])
}
} catch (error) {
pagingRef.value?.complete(false)
}
}
const keyboardIsShow = ref(false)
const keyboardHeightChange = (res: any) => {
if (res.height > 0) {
keyboardIsShow.value = true
} else {
keyboardIsShow.value = false
}
}
const hidedKeyboard = () => {
keyboardIsShow.value = false
}
const handleLongpress = async () => {
await recorderRef.value.startRecord()
showRecorder.value = true
vibrate(100)
}
const pluginChange = (value: string) => {
console.log(value)
if (value === 'xinghuo-chatdoc') {
pluginOptions.type = 'file'
} else if (value === 'gpt-4-all') {
pluginOptions.type = 'image'
} else if (value === 'kimi-chatdoc') {
pluginOptions.type = 'file'
} else {
pluginOptions.type = ''
}
resetPluginData()
}
const resetPluginData = () => {
pluginOptions.file = { url: '' }
}
watch(
() => props.otherId,
(value) => {
setTimeout(() => {
if (value) {
actions.value = ''
pagingRef.value?.reload()
} else {
pagingRef.value?.complete([])
setTimeout(() => {
pagingRef.value?.scrollToTop(false)
}, 100)
}
}, 10)
},
{
immediate: true
}
)
watch(
() => modelKey.value,
(value) => {
pluginOptions.pluginId = ''
}
)
const triggerRecordShow = async () => {
//#ifdef APP-PLUS
uni.$u.toast('相关功能正在开发中')
return Promise.reject()
//#endif
if (showPressBtn.value) {
showPressBtn.value = false
} else {
await getRecordAuth()
pauseAll()
showPressBtn.value = true
}
}
const triggerVoiceShow = async () => {
//#ifdef APP-PLUS
uni.$u.toast('相关功能正在开发中')
return Promise.reject()
//#endif
await getRecordAuth()
pauseAll()
showOnlineVoice.value = true
}
const getRecordAuth = async () => {
if (!userStore.isLogin) {
toLogin()
return Promise.reject()
}
try {
await authorize()
} catch (error) {
uni.$u.toast(error)
return Promise.reject()
}
}
const recorderRef = shallowRef()
const touchEnd = () => {
recorderRef.value?.stopRecord()
}
//
const posterRef = shallowRef()
const handleDrawPoster = async (recordId: number) => {
const result = chatList.value.filter((item: any) => {
return item.id == recordId
})
if (result.length != 2) {
uni.$u.toast('上下文数据不对~')
return
}
posterRef.value.initPosterData({
title: result[1].content,
content: result[0].content
})
}
const { lockFn: rewrite } = useLockFn(async (index: number) => {
if (isReceiving.value) return
const last = chatList.value[index]
const userInput = chatList.value.findLast(({ id }) => id === last.id)
if (userInput) {
await delChatRecord({
id: last.id
})
// eslint-disable-next-line vue/no-mutating-props
chatList.value.splice(index, 2)
sendLock(userInput.content)
}
})
const { lockFn: cleanChatLock } = useLockFn(async () => {
if (!userStore.isLogin) return toLogin()
const modal = await uni.showModal({
title: '温馨提示',
content: '确定清空对话?'
})
if (modal.cancel) return
chatClose()
await cleanChatRecord({
type: props.type,
otherId: props.type !== 1 ? props.otherId : '',
categoryId: props.type == 1 ? props.otherId : ''
})
pagingRef.value?.reload()
})
const scrollToBottom = async () => {
pagingRef.value?.scrollToBottom(false)
}
const isReceiving = ref(false)
let streamReader: any = null
const chatClose = () => {
//#ifdef H5
streamReader?.abort()
//#endif
//#ifdef MP-WEIXIN
streamReader?.abort()
//#endif
//#ifdef APP-PLUS
appChatRef.value.stop()
//#endif
setTimeout(() => {
userInput.value = ''
})
}
const chatContent = ref<any>({})
const { pauseAll: pauseAllVoice } = useAudioPlay()
const { lockFn: sendLock } = useLockFn(async (text: string) => {
showRecorder.value = false
if (!userStore.isLogin) {
return toLogin()
}
if (isReceiving.value) return
if (userStore.userInfo.chatLimit && isNewDay(true, CHAT_LIMIT_KEY)) {
const res = await uni.showModal({
title: '对话上限提示',
content: '已超过会员对话上限次数,继续对话将会消耗账户的对话余额',
confirmText: '继续',
cancelText: '关闭'
})
if (res.cancel) return (isReceiving.value = false)
}
const inputValue = text || userInput.value
if (!inputValue) {
uni.$u.toast(props.placeholder)
isReceiving.value = false
return
}
if (props.type == 1) {
if (sessionActive.value === 0) {
await sessionAdd()
}
if (currentSession.value === '新的会话') {
await sessionEdit(sessionActive.value, inputValue)
}
}
newUserInput.value = userInput.value
userInput.value = ''
const options = {
chatKey: chatKey.value,
modelKey: pluginOptions.pluginId ? pluginOptions.pluginId : modelKey.value,
question: inputValue,
type: props.type,
categoryId: props.otherId,
otherId: props.otherId,
network: network.value,
file: pluginOptions.file.url
}
pagingRef.value.addChatRecordData({
type: 1,
content: inputValue,
fileUrl: pluginOptions.file.url,
fileName: pluginOptions.file.name,
fileType:
pluginOptions.type == '' ? 0 : pluginOptions.type == 'image' ? 1 : 2
})
chatContent.value = {
type: 2,
loading: true,
content: [] as string[]
}
pagingRef.value.addChatRecordData(chatContent.value)
//#ifdef APP-PLUS
appChatRef.value.getParamsData(options)
//#endif
//#ifndef APP-PLUS
try {
isReceiving.value = true
await chatSendText(options, {
onstart(reader) {
streamReader = reader
pauseAllVoice()
userInput.value = ''
emit('reader', reader)
},
onmessage(value) {
value
.trim()
.split('data:')
.forEach(async (text) => {
if (text !== '') {
try {
const dataJson = JSON.parse(text)
console.log(dataJson)
const { id, event, data, error, index } = dataJson
if (error && error?.errCode == 336) {
userInput.value = newUserInput.value
guidedPopupRef.value?.open()
return
} else if (error) {
uni.$u.toast(error.errMsg)
isReceiving.value = false
return
}
// let chatIndex = chatList.value.findIndex(
// (item) => item.id === chatId
// )
// if (chatIndex === -1) {
// chatIndex = chatList.value.length - 1
// chatList.value[chatIndex].id = chatId
// }
//
// if (data) {
// if (!chatList.value[chatIndex].content[index]) {
// chatList.value[chatIndex].content[index] = ''
// }
// chatList.value[chatIndex].content[index] += data
// }
if (data) {
if (!chatContent.value.content[index]) {
chatContent.value.content[index] = ''
}
chatContent.value.content[index] += data
}
if (event === 'finish') {
chatContent.value.loading = false
return
}
} catch (error) {
console.log('转换失败=>', error)
}
}
})
},
onclose() {
isReceiving.value = false
setTimeout(() => {
pagingRef.value?.reload()
}, 600)
}
})
} catch (error: any) {
console.log('发送消息失败=>', error)
if (error.errMsg !== RequestErrMsgEnum.ABORT) {
chatList.value.splice(chatList.value.length - 2, 2)
}
userInput.value = newUserInput.value
isReceiving.value = false
}
//#endif
})
//#ifdef APP-PLUS
const appOnmessage = (value: any) => {
value
.trim()
.split('data:')
.forEach(async (text: any) => {
if (text !== '') {
try {
const dataJson = JSON.parse(text)
console.log(dataJson)
const { id, event, data, error, index } = dataJson
if (error && error?.errCode == 336) {
userInput.value = newUserInput.value
guidedPopupRef.value?.open()
return
} else if (error) {
uni.$u.toast(error.errMsg)
isReceiving.value = false
return
}
if (data) {
if (!chatContent.value.content[index]) {
chatContent.value.content[index] = ''
}
chatContent.value.content[index] += data
}
if (event === 'finish') {
chatContent.value.loading = false
return
}
} catch (error) {}
}
})
}
const appOnclose = (value: any) => {
isReceiving.value = false
setTimeout(() => {
pagingRef.value?.reload()
}, 600)
}
const appOnstart = (value: any) => {
// console.log(value)
streamReader = value
emit('reader', value)
}
//#endif
const toLogin = () => {
router.navigateTo({ path: '/pages/login/login' })
}
const setUserInput = (value = '') => {
userInput.value = value
}
onUnmounted(() => {
chatClose()
})
watch(sessionActive, async (value) => {
if (value) {
chatClose()
}
})
onShow(() => {
//inputoffKeyboardHeightChangebug
setTimeout(() => {
// H5
// #ifndef H5 || MP-BAIDU || MP-TOUTIAO
uni.onKeyboardHeightChange(pagingRef.value?._handleKeyboardHeightChange)
// #endif
}, 100)
})
defineExpose({
scrollToBottom,
setUserInput,
sendLock,
rewrite
})
</script>
<style lang="scss" scoped>
.chat-scroll-view {
.send-area {
position: relative;
padding: 20rpx 30rpx;
background-color: #fff;
.float-btn {
position: absolute;
left: 50%;
top: -10rpx;
transform: translate(-50%, -100%);
z-index: 100;
border: 1px solid;
border-radius: 20rpx;
@apply bg-white border-light;
}
&__content {
border-radius: 16rpx;
padding: 25rpx 20rpx;
position: relative;
display: flex;
align-items: center;
:deep() {
.u-input__textarea {
--line-height: 40rpx;
--line-num: 4;
height: auto;
min-height: var(--line-height) !important;
max-height: calc(var(--line-height) * var(--line-num));
font-size: 28rpx;
box-sizing: border-box;
padding: 0;
line-height: var(--line-height);
.uni-textarea-textarea {
max-height: calc(var(--line-height) * var(--line-num));
overflow-y: auto !important;
}
}
}
.send-btn {
width: 100%;
position: absolute;
right: 0rpx;
bottom: 10rpx;
z-index: 99;
padding: 0 20rpx;
}
}
}
}
.chat-bubble {
width: 70rpx;
height: 70rpx;
}
</style>

View File

@ -0,0 +1,148 @@
<template>
<view>
<text
:paramsData="paramsData"
:change:paramsData="appChatRender.getData"
></text>
<text
:requestData="requestData"
:change:requestData="appChatRender.getRequestData"
></text>
<!--暂停-->
<text
:stopCont="stopCont"
:change:stopCont="appChatRender.stopMid"
></text>
</view>
</template>
<script lang="ts">
import { nextTick, ref, shallowRef } from 'vue'
import { useUserStore } from '@/stores/user'
import config from '@/config'
import { client } from '@/utils/client'
interface IParamsData {
model: string
question: string
type: number
other_id: number
}
export default {
setup(props, context) {
let { token } = useUserStore()
let baseUrl = ''
const requestData: any = ref({})
const paramsData = ref({})
//
const stopCont: any = ref(0)
const stop = () => {
stopCont.value++
}
const onmessage = (value: any) => {
context.emit('onmessage', value)
}
const onclose = () => {
context.emit('onclose')
}
const onstart = (reader: any) => {
console.log(reader.method)
context.emit('onstart', reader)
paramsData.value = {}
}
const getParamsData = async (data: any) => {
token = useUserStore().token
baseUrl = config.baseUrl
requestData.value.baseUrl = baseUrl
requestData.value.token = token
requestData.value.terminal = client
setTimeout(() => {
Object.keys(data).map((item) => {
//@ts-ignore
paramsData.value[item] = data[item]
})
}, 500)
}
return {
getParamsData,
stop,
paramsData,
requestData,
stopCont,
onmessage,
onclose,
onstart
}
}
}
</script>
<script module="appChatRender" lang="renderjs">
// import { chatSendText } from '@/api/chat'
import { getChat } from "./request/index";
export default {
data(){
return{
paramData:{},
requestData:{},
count:0,
stopMethod:{},
evn:{}
}
},
methods:{
async getData(newValue, oldValue, ownerInstance, instance){
this.paramData = newValue
if(this.count>0&&Object.keys(this.paramData).length!=0){
await this.sendChat({},ownerInstance)
}
this.count++
},
async getRequestData(newValue, oldValue, ownerInstance, instance){
this.requestData.token = newValue.token
this.requestData.baseUrl = newValue.baseUrl
this.requestData.terminal = newValue.terminal
},
async stopMid(newValue, oldValue, ownerInstance, instance){
this.stop()
},
sendChat(event,ownerInstance){
console.log(JSON.stringify(this.paramData))
try{
let that = this
getChat({...this.paramData},{
onstart(reader) {
console.log('开始对话')
that.stopMethod = reader.cancel.bind(reader)
ownerInstance.callMethod('onstart', {method:{}})
},
onmessage(value) {
ownerInstance.callMethod('onmessage', value)
},
onclose(value) {
console.log('对话结束', value)
ownerInstance.callMethod('onclose', value)
},
baseUrl:this.requestData.baseUrl,
token:this.requestData.token,
terminal: this.requestData.terminal
})
}catch(e){
console.log('错误了', e)
//TODO handle the exception
}
},
stop(){
this.stopMethod()
}
}
}
</script>

View File

@ -0,0 +1,431 @@
<template>
<u-popup
v-model="showModel"
mode="bottom"
safe-area-inset-bottom
:mask="false"
height="100%"
z-index="99999"
:custom-style="{
background: '#191820'
}"
>
<view class="h-full flex flex-col py-[120rpx] text-white">
<view class="mt-[80rpx] flex flex-col justify-center items-center">
<view class="relative">
<u-image
shape="circle"
width="220"
height="220"
:src="appStore.getChatConfig.chatLogo"
/>
<view
v-if="chatStatus == ChatStatus.PLAYING"
class="bubble-loading flex justify-center absolute"
>
<loading size="9rpx" class="mt-[24rpx]" />
</view>
</view>
<view class="text-3xl mt-[20rpx]">{{
appStore.getChatConfig.chatTitle
}}</view>
</view>
<view class="flex-1 flex flex-col justify-center items-center">
<canvas
v-show="isRecording"
:style="{
width: `${canvasOptions.width}px`,
height: `${canvasOptions.height}px`
}"
:canvas-id="canvasOptions.id"
/>
<image
v-if="
[
ChatStatus.TRANSFER,
ChatStatus.PLAYING,
ChatStatus.THINKING
].includes(chatStatus)
"
:src="loadingPath"
class="w-[250rpx] h-[250rpx]"
/>
</view>
<view>
<view class="flex justify-center relative">
<view>
<view class="action-btn" @click="showModel = false">
<u-icon name="close" :size="32"></u-icon>
</view>
</view>
<view class="relative flex justify-center items-center">
<view
class="w-[170rpx] h-[170rpx] rounded-[50%] bg-primary opacity-30 absolute"
>
</view>
<button
class="flex justify-center items-center w-[140rpx] h-[140rpx] rounded-[50%] bg-primary text-btn-text relative z-10"
@click="startRecord"
hover-class="none"
>
<u-icon v-if="isCanRecord" name="mic" :size="60" />
<view v-if="isRecording" class="stop"> </view>
<loading
v-if="
[
ChatStatus.TRANSFER,
ChatStatus.THINKING,
ChatStatus.INITIALING
].includes(chatStatus)
"
/>
<u-icon
v-if="chatStatus == ChatStatus.PLAYING"
name="pause-circle-fill"
:size="60"
/>
</button>
</view>
</view>
<view
class="flex flex-col justify-center items-center mt-[42rpx] text-xs"
>
<view>
{{ statusToTextMap[chatStatus] }}
</view>
</view>
</view>
</view>
<guided-popup ref="guidedPopupRef" />
</u-popup>
</template>
<script lang="ts">
export default {
options: {
styleIsolation: 'shared'
}
}
</script>
<script setup lang="ts">
import {
audioTransfer,
chatSendText,
getChatVoiceGenerate,
getChatRecord
} from '@/api/chat'
import { useAudioPlay } from '@/hooks/useAudioPlay'
import {
AudioGraphUserOptions,
useRecorder,
useRenderAudioGraph
} from '@/hooks/useRecorder'
import { useAppStore } from '@/stores/app'
import { reactive } from 'vue'
import { shallowRef } from 'vue'
import { computed } from 'vue'
import { watch } from 'vue'
import { ref } from 'vue'
import { parse } from 'jsonc-parser'
import config from '@/config'
enum ChatStatus {
INITIALING,
//
DEFAULT,
//
RECORDING,
//
RECORDING_EMPTY,
//
TRANSFER,
//
TRANSFER_EMPTY,
//
TRANSFER_ERROR,
//
THINKING,
//
THINKING_ERROR,
//
PLAYING
}
const props = defineProps<{
show: boolean
data: Record<string, any>
}>()
const emit = defineEmits<{
(event: 'update:show', show: boolean): void
(event: 'update'): void
}>()
const statusToTextMap = reactive({
[ChatStatus.INITIALING]: '正在初始化对话...',
[ChatStatus.DEFAULT]: '点击开始说话',
[ChatStatus.RECORDING]: '我在听,您请说...',
[ChatStatus.RECORDING_EMPTY]: '我好像没太听清,你可以再说一遍',
[ChatStatus.TRANSFER]: '正在加载中,请稍后...',
[ChatStatus.TRANSFER_EMPTY]: '您好像没有说话,点击按钮重试',
[ChatStatus.TRANSFER_ERROR]: '出错了,请重试',
[ChatStatus.THINKING]: '稍等,让我想一想',
[ChatStatus.THINKING_ERROR]: '出错了,请重试',
[ChatStatus.PLAYING]: '正在回复,点击按钮打断'
})
const loadingUrl = '/api/static/voice.gif'
let loadingPath = `${config.baseUrl}${loadingUrl}`
//#ifdef H5
loadingPath = `${
config.baseUrl === '/' ? `${location.origin}/` : config.baseUrl
}${loadingUrl}`
//#endif
const appStore = useAppStore()
const guidedPopupRef = shallowRef()
const showModel = computed({
get() {
return props.show
},
set(value) {
emit('update:show', value)
}
})
const isCanRecord = computed(() => {
return [
ChatStatus.DEFAULT,
ChatStatus.TRANSFER_ERROR,
ChatStatus.TRANSFER_EMPTY,
ChatStatus.TRANSFER_ERROR,
ChatStatus.THINKING_ERROR
].includes(chatStatus.value)
})
const isAutoStop = ref(false)
const chatStatus = ref(ChatStatus.INITIALING)
const startTimer = ref(0)
const hasVoice = ref(false)
const recordTimer = ref(0)
const { start, stop, isRecording, authorize, close } = useRecorder({
onstart() {
hasVoice.value = false
startTimer.value = Date.now()
chatStatus.value = ChatStatus.RECORDING
},
async onstop(result) {
draw(null, 0)
stopRender()
if (!isAutoStop.value) {
if (!isCanRecord.value) {
chatStatus.value = ChatStatus.DEFAULT
}
return
}
isAutoStop.value = false
chatStatus.value = ChatStatus.TRANSFER
try {
const res: any = await audioTransfer(result.tempFilePath, {
type: 3
})
if (!res.text) {
chatStatus.value = ChatStatus.TRANSFER_EMPTY
return
}
chat(res.text, res.file)
} catch (error) {
chatStatus.value = ChatStatus.TRANSFER_ERROR
}
},
ondata(result) {
render(result)
const now = Date.now()
//
if (result.powerLevel > 6) {
clearTimeout(recordTimer.value)
chatStatus.value = ChatStatus.RECORDING
hasVoice.value = true
startTimer.value = now
//1s
recordTimer.value = setTimeout(() => {
isAutoStop.value = true
stop()
}, 2000)
}
// 5s
if (now - startTimer.value >= 5000) {
if (!hasVoice.value) {
if (chatStatus.value == ChatStatus.RECORDING_EMPTY) {
//5s
stop()
chatStatus.value = ChatStatus.TRANSFER_EMPTY
return
}
chatStatus.value = ChatStatus.RECORDING_EMPTY
startTimer.value = now
}
}
}
})
const canvasOptions = reactive<AudioGraphUserOptions>({
id: 'audio-canvas',
width: 100,
height: 40,
minHeight: 5
})
const { render, stopRender, draw } = useRenderAudioGraph(canvasOptions)
let streamReader: any = null
const recordId = ref(0)
const { play, pause, destroy } = useAudioPlay({
api: getChatVoiceGenerate,
dataTransform(data) {
return data.file_url
},
params: reactive({
records_id: recordId
}),
onstart() {
chatStatus.value = ChatStatus.PLAYING
},
onstop() {
destroy()
showModel.value && start()
}
})
const chat = (text: string, filePath: string) => {
chatStatus.value = ChatStatus.THINKING
try {
chatSendText(
{
...props.data,
voice_file: filePath,
question: text
},
{
onstart(reader) {
streamReader = reader
},
onmessage(value) {
value
.trim()
.split('data:')
.forEach(async (text) => {
const dataJson = parse(text)
if (!dataJson) return
const { event, data, error, code } = dataJson
if (error && error?.errCode == 336) {
chatStatus.value = ChatStatus.DEFAULT
guidedPopupRef.value?.open()
} else if (error) {
uni.$u.toast(error.errMsg)
console.log(dataJson)
chatStatus.value = ChatStatus.THINKING_ERROR
}
if (event === 'finish') {
setTimeout(async () => {
try {
const list = await getChatList()
recordId.value =
list[list.length - 1].id
showModel.value && (await play())
emit('update')
} catch (error) {
chatStatus.value =
ChatStatus.THINKING_ERROR
}
}, 100)
}
})
},
onclose() {}
}
)
} catch (error) {
chatStatus.value = ChatStatus.THINKING_ERROR
}
}
const getChatList = async () => {
const data = await getChatRecord(props.data)
return data || []
}
const chatClose = () => {
//#ifdef H5
streamReader?.cancel()
//#endif
//#ifdef MP-WEIXIN
streamReader?.abort()
//#endif
}
const startRecord = async () => {
if (isRecording.value) {
stop()
return
}
if (isCanRecord.value) {
start()
return
}
if (chatStatus.value == ChatStatus.PLAYING) {
pause()
}
}
watch(showModel, async (value) => {
if (!value) {
if (isRecording.value) {
stop()
}
chatClose()
// close()
pause()
emit('update')
destroy()
} else {
chatStatus.value = ChatStatus.INITIALING
// #ifdef H5
await authorize()
// #endif
chatStatus.value = ChatStatus.DEFAULT
draw(null, 0)
}
})
</script>
<style lang="scss" scoped>
.action-btn {
position: absolute;
width: 76rpx;
height: 76rpx;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
background: #999;
top: 50%;
left: 64rpx;
transform: translateY(-50%);
}
.stop {
width: 44rpx;
height: 44rpx;
border-radius: 10rpx;
@apply bg-btn-text;
}
.bubble-loading {
width: 60rpx;
height: 60rpx;
background: url(../../../static/images/common/bubble_bg.png) no-repeat;
background-size: cover;
right: 0;
top: 0;
transform: translate(20%, -40%);
}
</style>

View File

@ -0,0 +1,202 @@
import { merge } from 'lodash-es'
import {Base64} from 'js-base64'
import { useUserStore } from '@/stores/user'
import type {
HttpRequestOptions,
RequestConfig,
RequestEventStreamConfig,
RequestHooks,
RequestOptions
} from './type'
import { RequestCodeEnum, RequestMethodsEnum } from '@/enums/requestEnums'
function isStreamResponse(contentType?: string) {
if (typeof contentType !== 'string') return false
return contentType.includes('text/event-stream')
}
const requestHooks: RequestHooks = {
requestInterceptorsHook(options, config) {
const { urlPrefix, baseUrl, withToken } = config
options.header = options.header || {}
if (urlPrefix) {
options.url = `${urlPrefix}${options.url}`
}
if (baseUrl) {
options.url = `${baseUrl}${options.url}`
}
const token = config.token
options.header['terminal'] = config!.terminal
// 添加token
if (withToken && !options.header.token) {
options.header['ai-token'] = token
}
return options
},
async responseInterceptorsHook(response, config, options) {
const { isTransformResponse, isReturnDefaultResponse, isAuth } = config
//返回默认响应,当需要获取响应头及其他数据时可使用
if (isReturnDefaultResponse) {
return response
}
// 是否需要对数据进行处理
if (!isTransformResponse) {
return response.data
}
// const { logout } = useUserStore()
const { code, data, msg, show } = response.data as any
switch (code) {
case RequestCodeEnum.SUCCESS:
// msg && show && uni.$u.toast(msg)
return data
case RequestCodeEnum.FAILED:
// msg && uni.$u.toast(msg)
return Promise.reject(msg)
case RequestCodeEnum.TOKEN_INVALID:
// logout()
if (isAuth && options.method?.toUpperCase() !== 'GET') {
// router.navigateTo({ path: '/pages/login/login' })
}
return Promise.reject(msg)
default:
return data
}
},
async responseInterceptorsCatchHook(options, error) {
if (options.method?.toUpperCase() == RequestMethodsEnum.POST) {
// uni.$u.toast('请求失败,请重试')
}
return error
}
}
export const defaultOptions: HttpRequestOptions = {
requestOptions: {
timeout: 30 * 1000
},
// baseUrl: `${import.meta.env.VITE_APP_BASE_URL || ''}/`,
baseUrl: '',
//是否返回默认的响应
isReturnDefaultResponse: false,
// 需要对返回数据进行处理
isTransformResponse: true,
// 接口拼接地址
urlPrefix: 'api',
// 忽略重复请求
ignoreCancel: false,
// 是否携带token
withToken: true,
isAuth: false,
retryCount: 2,
retryTimeout: 1000,
requestHooks: requestHooks,
token: ''
}
export const eventStream = (
options: RequestOptions,
config: RequestEventStreamConfig
) => {
let mergeOptions = merge({}, defaultOptions.requestOptions, options)
const mergeConfig: RequestEventStreamConfig = merge(
{},
defaultOptions,
config
)
const { requestInterceptorsHook, responseInterceptorsHook } =
mergeConfig.requestHooks || {}
// if (requestInterceptorsHook && isFunction(requestInterceptorsHook)) {
mergeOptions = requestInterceptorsHook(
mergeOptions,
mergeConfig as RequestConfig
)
// }
let body: any = undefined
body = JSON.stringify(mergeOptions.data)
const { onmessage, onclose, onstart } = config
const decoder = new TextDecoder()
const push = async (controller: any, reader: any) => {
try {
const { value, done } = await reader.read()
if (done) {
controller.close()
onclose?.()
} else {
const tempval = Base64.decode(decoder.decode(value))
onmessage?.(tempval)
controller.enqueue(value)
push(controller, reader)
}
} catch (error) {
onclose?.()
}
}
return new Promise((resolve, reject) => {
console.log('asdas-----------------d', JSON.stringify(mergeOptions))
fetch(mergeOptions.url, {
...mergeOptions,
body,
headers: {
'content-type': 'application/json; charset=utf-8',
Accept: 'text/event-stream',
...mergeOptions.header
}
})
.then(async (response) => {
if (response.status == 200) {
if (
isStreamResponse(response.headers?.get('content-type')!)
) {
const reader = response.body!.getReader()
// console.log(reader)
onstart?.(reader)
new ReadableStream({
start(controller) {
push(controller, reader)
}
})
} else {
//@ts-ignore
response.data = await response.json()
return response
}
} else {
reject(response.statusText)
}
})
.then(async (response: any) => {
if (!response) {
resolve(response)
return
}
if (responseInterceptorsHook) {
try {
response = await responseInterceptorsHook(
response,
mergeConfig as RequestConfig,
mergeOptions
)
resolve(response)
} catch (error) {
reject(error)
}
return
}
resolve(response)
})
.catch((error) => {
reject(error)
})
})
}

View File

@ -0,0 +1,13 @@
import { eventStream } from './http'
export const getChat = (data: any, config: any) => {
console.log('qweqwe----------------', config)
return eventStream(
{
url: '/chats/chatSend',
data,
method: 'POST'
},
config
)
}

View File

@ -0,0 +1,44 @@
export type RequestOptions = UniApp.RequestOptions
export type ResponseResult =
| UniApp.RequestSuccessCallbackResult
| UniApp.UploadFileSuccessCallbackResult
export type RequestOptionsResponseError = UniApp.GeneralCallbackResult
export type RequestTask = UniApp.RequestTask
export type UploadFileOption = UniApp.UploadFileOption
export interface HttpRequestOptions extends RequestConfig {
requestOptions: Partial<RequestOptions>
}
export interface RequestConfig {
baseUrl: string
requestHooks: RequestHooks
isReturnDefaultResponse: boolean
isTransformResponse: boolean
urlPrefix: string
ignoreCancel: boolean
withToken: boolean
isAuth: boolean
retryCount: number
retryTimeout: number
hasRetryCount?: number
token: string
}
export interface RequestEventStreamConfig extends Partial<RequestConfig> {
onstart?: (event: ReadableStreamDefaultReader | UniApp.RequestTask) => void
onmessage?: (value: string) => void
onclose?: () => void
}
export interface RequestHooks {
requestInterceptorsHook?(
options: RequestOptions,
config: RequestConfig
): RequestOptions
responseInterceptorsHook?(
response: ResponseResult,
config: RequestConfig,
options: RequestOptions
): any
responseInterceptorsCatchHook?(options: RequestOptions, error: any): any
}

View File

@ -0,0 +1,385 @@
<template>
<l-painter
ref="painterRef"
:isCanvasToTempFilePath="false"
:css="`width: 640rpx;`"
custom-style="position: fixed; left: 200%;"
>
<l-painter-view
:css="`
border-radius: 14rpx;
overflow: hidden;
background-color: ${getPosterBgColor};
`"
>
<l-painter-view
:css="`
display: block;
left: 0;
top: 0;
position: absolute;
width: 100%;
z-index: -1;
`"
>
<l-painter-image :src="posterBg" css="width: 100%;" />
</l-painter-view>
<!-- 主要视图 -->
<l-painter-view
:css="`
display: block;
box-sizing: border-box;
border-radius: 14rpx;
width: 100%;
height: 100%;
padding: 0 40rpx;
`"
>
<!-- 中部内容区域 -->
<l-painter-view
:css="`
box-sizing: border-box;
padding: 30rpx;
border-radius: 20rpx;
background: #fff;
margin-top: 240rpx;
display: block;
`"
>
<l-painter-view
:css="`
box-sizing: border-box;
display: flex;
justify-content: flex-end;
`"
>
<l-painter-text
:text="state?.title"
:css="`
line-clamp: 2;
background-color: #066cff;
border-radius: 16rpx 0 16rpx 16rpx;
padding: 20rpx 20rpx;
color: #ffffff;
font-size: 26rpx;
`"
/>
</l-painter-view>
<l-painter-view
:css="`
box-sizing: border-box;
`"
>
<l-painter-text
:text="state?.content"
:css="`
color: #333333;
font-size: 26rpx;
padding: 20rpx;
margin-top: 20rpx;
background-color: #f0f5fe;
border-radius: 0 16rpx 16rpx 16rpx;
${
state?.options?.showContentType == 1
? `line-clamp: ${state?.options?.contentNum}`
: ''
}
`"
/>
</l-painter-view>
</l-painter-view>
<!-- 底部信息区域 -->
<l-painter-view
:css="`
display: block;
margin-top: 30rpx;
padding-bottom: 40rpx;
`"
>
<!-- 个人信息区域 -->
<l-painter-view
:css="`display: inline-block; width: 380rpx; margin-top: 40rpx;`"
>
<l-painter-image
:src="userInfo?.avatar"
css="width: 110rpx; height: 110rpx; border-radius: 50%;display: inline-block;"
/>
<l-painter-view
:css="`
width: 270rpx;
height: 110rpx;
vertical-align: middle;
margin-top: 18rpx;
padding-left: 20rpx;
display: inline-block;
box-sizing: border-box;
`"
>
<l-painter-text
:text="userInfo?.nickname"
:css="`color: ${state?.options?.textColor}; font-size: 26rpx;line-clamp:1;width: 200rpx;`"
/>
<l-painter-text
v-if="state?.options?.showData"
:text="state?.options?.data"
:css="`color: ${state?.options?.textColor}; font-size: 26rpx;line-clamp:1;width: 200rpx;`"
/>
</l-painter-view>
</l-painter-view>
<!-- 扫码区域 -->
<l-painter-view
:css="`display: inline-block;width: 180rpx; text-align: center;`"
>
<!-- H5二维码 -->
<!-- #ifdef H5 || APP-PLUS -->
<l-painter-qrcode
:css="`
box-sizing: border-box;
width: 170rpx;
height: 170rpx;
padding: 10rpx;
border-radius: 8rpx;
background-color: #FFFFFF;
`"
:text="state.invite_link"
>
</l-painter-qrcode>
<!-- #endif -->
<!-- 小程序二维码 -->
<!-- #ifdef MP -->
<l-painter-image
:src="state.mp_qr_code"
:css="`
box-sizing: border-box;
width: 170rpx;
height: 170rpx;
border-radius: 8rpx;
background-color: #FFFFFF;
`"
/>
<!-- 邀请文案 -->
<!-- #endif -->
<l-painter-text
text="长按识别二维码"
:css="`
display: block;
color: ${state?.options?.textColor};
font-size: 24rpx;
text-align: center;
margin-top: 4rpx;
`"
/>
</l-painter-view>
</l-painter-view>
</l-painter-view>
</l-painter-view>
</l-painter>
<!-- 弹窗海报 -->
<u-popup
v-model="state.showPoster"
mode="center"
:closeable="true"
closeIconColor="#FFFFFF"
:safe-area-inset-bottom="true"
:customStyle="{
background: 'none'
}"
>
<view class="pb-[30rpx]">
<!-- #ifndef H5 -->
<image
style="width: 640rpx"
mode="widthFix"
:src="state.poster_url"
></image>
<!-- #endif -->
<!-- #ifdef H5 -->
<img style="width: 640rpx" :src="state.poster_url" />
<!-- #endif -->
<view class="mt-[20rpx]">
<u-button
type="primary"
shape="circle"
size="default"
:customStyle="{
padding: '0 30rpx',
height: '82rpx'
}"
@click="handleSave"
>
<!-- #ifndef H5 -->
保存图片到相册
<!-- #endif -->
<!-- #ifdef H5 -->
长按保存图片到相册
<!-- #endif -->
</u-button>
</view>
</view>
</u-popup>
</template>
<script lang="ts" setup>
import { nextTick, reactive, ref, computed } from 'vue'
import { useAppStore } from '@/stores/app'
import { getMnpQrCode } from '@/api/app'
import { getDecorate } from '@/api/shop'
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'
import appConfig from '@/config'
const { getImageUrl } = useAppStore()
const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore)
const painterRef = ref()
const state = reactive<{
showPoster: boolean
title: string //
content: string //
mp_qr_code: string //
invite_link: string // H5
options: any //
poster_url: string //
}>({
showPoster: false,
title: '',
content: '',
mp_qr_code: '',
invite_link: '',
options: {
data: '邀请您前来体验',
default: 1,
poster: 1,
defaultUrl1: '',
defaultUrl2: '',
posterUrl: '',
showData: '1'
},
poster_url: ''
})
const posterBg = ref('')
const getPosterBg = () => {
const data = state?.options
if (data.default == 1 && data.poster == 1) {
// 1
posterBg.value = getImageUrl(data.defaultUrl1)
} else if (data.default == 1 && data.poster == 2) {
// 2
posterBg.value = getImageUrl(state?.options.defaultUrl2)
} else if (data.default == 2) {
//
posterBg.value = getImageUrl(data.posterUrl)
}
}
const getPosterBgColor = computed(() => {
const data = state?.options
if (!data) {
return ''
}
console.log(data.bgColor)
return data.bgColor
})
const initPosterData = async (row: { title: string; content: string }) => {
await uni.showLoading({
title: '生成中'
})
try {
//
if (!userInfo?.value?.sn) {
await userStore.getUser()
}
const { pages } = await getDecorate({ type: 6 })
//
// #ifdef MP-WEIXIN
if (!state.mp_qr_code) {
const { qrcode } = await getMnpQrCode({
user_sn: userInfo.value.sn,
page: 'pages/index/index'
})
state.mp_qr_code = 'data:image/png;base64,' + qrcode
}
// #endif
// #ifdef H5
const domain = window.origin
state.invite_link = `${domain}/mobile/pages/index/index?user_sn=${userInfo.value.sn}`
// #endif
// #ifdef APP-PLUS
let domain = appConfig.baseUrl
if (domain.charAt(domain.length - 1) === '/') {
domain = domain.slice(0, -1)
}
state.invite_link = `${domain}/mobile/pages/index/index?user_sn=${userInfo.value.sn}`
// #endif
state.title = row.title.replace(/\n/g, '')
state.content = row.content
// .replace(/\n/g, '')
state.options = JSON.parse(pages)[0]?.content
getPosterBg()
await nextTick()
await handleDrawCanvas()
} catch (error) {
uni.$u.toast(error)
uni.hideLoading()
console.log('请求生产海报失败', error)
}
}
const handleSave = () => {
// #ifndef H5
uni.saveImageToPhotosAlbum({
filePath: state.poster_url,
success: () => {
uni.$u.toast('保存成功')
},
fail: (err) => {
uni.$u.toast('保存失败')
console.log(err)
}
})
// #endif
// #ifdef H5
uni.$u.toast('请长按图片保存')
// #endif
}
const handleDrawCanvas = async () => {
try {
//
painterRef.value?.canvasToTempFilePathSync({
fileType: 'png',
// base64使 saveImageToPhotosAlbum pathTypeurl
// #ifdef MP
pathType: 'url',
// #endif
// #ifdef H5
pathType: 'base64',
// #endif
quality: 1,
success: (res: any) => {
console.log('生产结果', res)
uni.hideLoading()
state.showPoster = true
state.poster_url = res.tempFilePath
},
fail: (error: any) => {
console.log(error)
uni.hideLoading()
uni.$u.toast('调用海报错误', error)
}
})
} catch (error) {
uni.hideLoading()
uni.$u.toast('调用海报错误', error)
}
}
defineExpose({ handleDrawCanvas, initPosterData })
</script>

View File

@ -0,0 +1,150 @@
<template>
<view
id="drag-button"
class="drag-container"
:class="{ transition: autoDocking && !moving }"
:style="{
left: `${left}px`,
top: `${top}px`,
width: `${size}rpx`,
height: `${size}rpx`,
zIndex: zIndex
}"
@touchend="touchend"
@touchmove.stop.prevent="touchmove"
>
<slot />
</view>
</template>
<script>
export default {
name: 'DragButton',
props: {
/**
* 按钮大小
*/
size: {
type: Number,
default: 200
},
/**
* 层级
*/
zIndex: {
type: Number,
default: 999
},
/**
* x轴边界限制
*/
xEdge: {
type: Number,
default: 0
},
/**
* y轴边界限制
*/
yEdge: {
type: Number,
default: 50
},
/**
* 自动停靠
*/
autoDocking: {
type: Boolean,
default: true
}
},
data() {
return {
top: 500,
left: 300,
width: 0,
height: 0,
moving: true
}
},
mounted() {
this.init()
},
methods: {
init() {
//
const { windowWidth, windowHeight, windowTop } =
uni.getSystemInfoSync()
this._windowWidth = windowWidth
this._windowHeight = windowHeight
if (windowTop) {
this._windowHeight += windowTop
}
//
const query = uni.createSelectorQuery().in(this)
query
.select('#drag-button')
.boundingClientRect((data) => {
if (!data) return
const { width, height } = data
this.width = width
this.height = height
this._offsetWidth = width / 2
this._offsetHeight = height / 2
this.left = this._windowWidth - this.width - this.xEdge
this.top = this._windowHeight - this.height - this.yEdge
})
.exec()
},
//
touchmove({ touches }) {
if (touches.length !== 1) return false
this.moving = true
const { clientX, clientY } = touches[0]
this.left = clientX - this._offsetWidth
const _clientY = clientY - this._offsetHeight
this.top = _clientY
},
//
touchend() {
//
if (this.autoDocking) {
const rigthEdge = this._windowWidth - this.width - this.xEdge
if (this.left < this._windowWidth / 2 - this._offsetWidth) {
this.left = this.xEdge
} else {
this.left = rigthEdge
}
}
//
const bottomEdge = this._windowHeight - this.height - this.yEdge
if (this.top < 50) {
this.top = 50
} else if (this.top > bottomEdge) {
this.top = bottomEdge
}
this.moving = false
}
}
}
</script>
<style lang="scss" scoped>
.drag-container {
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
position: fixed;
&.transition {
transition: all 0.3s ease;
}
}
</style>

View File

@ -0,0 +1 @@
<template> <view v-if="showDropdown" class="dropdown-mask" @click="showDropdown = !showDropdown" ></view> <view class="dropdown"> <view @click="showDropdown = !showDropdown"> <slot></slot> </view> <view class="dropdown-menu" v-show="showDropdown" :class="menuClass" @click="showDropdown = !showDropdown" > <!-- 下拉菜单的内容 --> <slot name="menu"></slot> </view> </view> </template> <script lang="ts" setup> import { computed, ref } from 'vue' const props = defineProps({ mode: 'down' // 默认向下展开 }) const showDropdown = ref<boolean>(false) const menuClass = computed(() => { return { 'dropdown-menu-up': props.mode === 'up', 'dropdown-menu-right': props.mode === 'right', 'dropdown-menu-down': props.mode === 'down', 'dropdown-menu-left': props.mode === 'left' } }) </script> <style scoped> .dropdown { position: relative; flex: 1; width: auto; } .dropdown-mask { position: absolute; z-index: 3; right: 0; left: 0; top: 0; bottom: 0; } .dropdown-menu { position: absolute; top: 100%; left: 0; flex: 1; width: auto; white-space: pre; border-radius: 12rpx; background-color: #fff; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); z-index: 9999; } .dropdown-menu-up { width: 100%; inset: auto auto 130% 0%; /*transform: translateX(-50%);*/ } .dropdown-menu-right { inset: auto auto 50% -130%; transform: translateY(-50%); } .dropdown-menu-down { inset: auto auto 0% 50%; transform: translateX(-50%); } .dropdown-menu-left { inset: auto auto 50% 130%; transform: translateY(-50%); } </style>

View File

@ -0,0 +1,247 @@
const ERR_MSG_FAIL = 'chooseFile:fail'
interface BaseOptions {
type: 'file' | 'image' | 'video'
}
type ChooseFileOptions = UniApp.ChooseFileOptions
type ChooseImageOptions = UniApp.ChooseImageOptions
type ChooseVideoOptions = UniApp.ChooseVideoOptions
type ChooseOptions = BaseOptions &
(ChooseFileOptions | ChooseImageOptions | ChooseVideoOptions)
export interface ChooseResult {
tempFilePaths: string[] | string
tempFiles: any[]
errMsg?: string
}
export interface FileData {
name: string
uuid: number
extname: string
fileType: string
url: string
path: string
size: string
progress: number
status: 'ready' | 'success' | 'error'
errMsg: string
}
function chooseImage(opts: ChooseImageOptions) {
const {
count,
sizeType = ['original', 'compressed'],
sourceType,
extension
} = opts
return new Promise<ChooseResult>((resolve, reject) => {
uni.chooseImage({
count,
sizeType,
sourceType,
extension,
success(res) {
resolve(normalizeFileRes(res as ChooseResult, 'image'))
},
fail(res) {
reject({
errMsg: res.errMsg.replace('chooseImage:fail', ERR_MSG_FAIL)
})
}
})
})
}
function chooseVideo(opts: ChooseVideoOptions) {
const { camera, compressed, maxDuration, sourceType, extension } = opts
return new Promise<ChooseResult>((resolve, reject) => {
uni.chooseVideo({
camera,
compressed,
maxDuration,
sourceType,
extension,
success(res) {
const { tempFilePath, duration, size, height, width } = res
resolve(
normalizeFileRes(
{
errMsg: 'chooseVideo:ok',
tempFilePaths: [tempFilePath],
tempFiles: [
{
name:
(res.tempFile && res.tempFile.name) ||
'',
path: tempFilePath,
size,
type:
(res.tempFile && res.tempFile.type) ||
'',
width,
height,
duration,
fileType: 'video'
}
]
},
'video'
)
)
},
fail(res) {
reject({
errMsg: res.errMsg.replace('chooseVideo:fail', ERR_MSG_FAIL)
})
}
})
})
}
function chooseAll(opts: ChooseFileOptions) {
const { count, extension } = opts
return new Promise<ChooseResult>((resolve, reject) => {
let chooseFile = uni.chooseFile
if (
typeof wx !== 'undefined' &&
typeof wx.chooseMessageFile === 'function'
) {
chooseFile = wx.chooseMessageFile
}
if (typeof chooseFile !== 'function') {
return reject({
errMsg:
ERR_MSG_FAIL +
' 请指定 type 类型,该平台仅支持选择 image 或 video。'
})
}
chooseFile({
type: 'all',
count,
extension,
success(res) {
resolve(normalizeFileRes(res as ChooseResult))
},
fail(res) {
reject({
errMsg: res.errMsg.replace('chooseFile:fail', ERR_MSG_FAIL)
})
}
})
})
}
function normalizeFileRes(res: ChooseResult, fileType?: BaseOptions['type']) {
res.tempFiles.forEach((item) => {
if (!item.name) {
item.name = item.path.substring(item.path.lastIndexOf('/') + 1)
}
if (fileType) {
item.fileType = fileType
}
})
if (!res.tempFilePaths) {
res.tempFilePaths = res.tempFiles.map((file) => file.path)
}
return res
}
function chooseFile(
opts: ChooseOptions = {
type: 'file'
}
): Promise<ChooseResult> {
if (opts.type === 'image') {
return chooseImage(opts as ChooseImageOptions)
} else if (opts.type === 'video') {
return chooseVideo(opts as ChooseVideoOptions)
}
return chooseAll(opts as ChooseFileOptions)
}
/**
* @description
* @param name
* @returns
*/
const getFileExt = (name: string) => {
const lastLen = name.lastIndexOf('.')
const len = name.length
const fileName = name.substring(0, lastLen)
const ext = name.substring(lastLen + 1, len)
return {
name: fileName,
ext
}
}
/**
* @description
* @param name
* @returns
*/
const getFileName = (path: string) => {
const lastLen = path.lastIndexOf('.')
const lastPath = path.lastIndexOf('/')
// 不是文件
if (lastLen === -1) return path
return path.substring(lastPath + 1)
}
const normalizeFileData = (files: any) => {
const fileFullName = getFileExt(files.name)
const extname = fileFullName.ext.toLowerCase()
const filedata: FileData = {
name: files.name,
uuid: files.uuid,
extname: extname || '',
fileType: files.fileType,
path: files.path,
url: files.path,
size: files.size,
progress: 0,
status: 'ready',
errMsg: ''
}
return filedata
}
// 检验扩展名是否正确
const getFilesByExtname = (res: ChooseResult, extname: string[] = []) => {
const filePaths: any[] = []
const files: any[] = []
if (!extname.length) {
return {
filePaths: res.tempFilePaths,
files: res.tempFiles
}
}
res.tempFiles.forEach((v) => {
const fileFullName = getFileExt(v.name)
const ext = fileFullName.ext.toLowerCase()
if (extname.indexOf(ext) !== -1) {
files.push(v)
filePaths.push(v.path)
}
})
if (files.length !== res.tempFiles.length) {
uni.showToast({
title: `当前选择了${res.tempFiles.length}个文件 ${
res.tempFiles.length - files.length
} `,
icon: 'none',
duration: 5000
})
}
return {
filePaths,
files
}
}
export {
chooseFile,
getFileExt,
getFilesByExtname,
normalizeFileData,
getFileName
}

View File

@ -0,0 +1,394 @@
<template>
<view
class="file-upload"
:class="{
'file-upload--line': limitLength === 1
}"
>
<view class="file-picker">
<view @click="choose">
<slot>
<view class="file-button">
<u-icon
name="arrow-upward"
:size="32"
v-if="fileType === 'file'"
/>
<u-icon name="camera" :size="32" v-else />
<view class="ml-[10rpx]">
{{ getBtnText }}
</view>
</view>
</slot>
</view>
</view>
<view class="file-list">
<view
class="file-item relative"
v-for="item in filesLists"
:key="item.url"
>
<view class="text-primary mr-[10rpx] flex">
<u-icon
v-if="fileType == 'file'"
:size="32"
name="file-text"
/>
<image
v-if="fileType == 'image'"
:src="item.path || item.url"
class="w-[32rpx] h-[32rpx]"
/>
<image
v-if="fileType == 'video'"
:src="item.path || item.url"
class="w-[32rpx] h-[32rpx]"
/>
</view>
<view class="flex-1 min-w-0 mr-[20rpx]">
<view class="line-clamp-1">
{{ item.name }}
</view>
</view>
<view
v-if="!disabled"
@click.stop="removeFile(item.url!)"
class="relative flex items-center z-10"
>
<u-icon name="close" :size="28" />
</view>
<view
class="absolute bottom-[0px] w-full left-0 flex"
v-if="
showProgress
&& item.progress! >= 0
&& item.progress
&& item.status !== 'success'
"
>
<u-line-progress
class="w-full h-auto"
height="6"
:show-percent="false"
:active-color="$theme.primaryColor"
:percent="item.progress"
></u-line-progress>
</view>
<view
v-if="item.status === 'error'"
class="absolute inset-0 bg-[rgba(0,0,0,0.4)] flex items-center justify-center text-white"
>
<view @click="retry(item)"> 点击重试 </view>
</view>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { PropType, computed, ref, watch } from 'vue'
import {
ChooseResult,
FileData,
chooseFile,
getFileName,
getFilesByExtname,
normalizeFileData
} from './choose-file'
import { uploadFile } from '@/api/app'
const props = defineProps({
modelValue: {
type: [Array, Object],
default() {
return []
}
},
limit: {
type: Number,
default: 10
},
disabled: {
type: Boolean,
default: false
},
//
fileType: {
type: String as PropType<'image' | 'video' | 'file'>,
default: 'all'
},
returnType: {
type: String as PropType<'object' | 'array'>,
default: 'array'
},
//
fileExtname: {
type: Array as PropType<string[]>,
default() {
return []
}
},
data: {
type: Object,
default() {
return {}
}
},
header: {
type: Object,
default() {
return {}
}
},
sizeType: {
type: Array as PropType<string[]>,
default() {
return ['original', 'compressed']
}
},
sourceType: {
type: Array as PropType<string[]>,
default() {
return ['album', 'camera']
}
},
showProgress: {
type: Boolean,
default: true
},
//
maxCount: {
type: Number,
default: 2
}
})
const emit = defineEmits<{
(event: 'update:modelValue', value: any): void
}>()
const filesLists = ref<Partial<FileData>[]>([])
const limitLength = computed(() => {
if (props.returnType === 'object') {
return 1
}
if (!props.limit) {
return 1
}
return props.limit
})
const choose = async () => {
if (props.disabled) return
if (
filesLists.value.length >= Number(limitLength.value) &&
props.returnType === 'array' &&
limitLength.value > 1
) {
uni.showToast({
title: `您最多选择 ${limitLength.value} 个文件`,
icon: 'none'
})
return
}
const filesResult = await chooseFile({
type: props.fileType,
compressed: false,
sizeType: props.sizeType,
sourceType: props.sourceType,
extension: props.fileExtname.length ? props.fileExtname : undefined,
count: limitLength.value - filesLists.value.length
})
chooseFileCallback(filesResult)
}
const chooseFileCallback = async (filesResult: ChooseResult) => {
const isOne = Number(limitLength.value) === 1
if (isOne) {
filesLists.value = []
}
const { files } = getFilesByExtname(filesResult, props.fileExtname)
const currentData = []
for (let i = 0; i < files.length; i++) {
if (limitLength.value - filesLists.value.length <= 0) break
const filedata = normalizeFileData(files[i])
filesLists.value.push(filedata)
currentData.push(filedata)
}
await upload(currentData)
emitUpdateValue()
}
const emitUpdateValue = () => {
let value: any = {}
if (props.returnType === 'object') {
const [item] = filesLists.value
console.log(item)
if (item?.status === 'success') {
value = {
url: item.url,
name: item.name
}
}
} else {
const data = filesLists.value.filter(
(item) => item.status === 'success'
)
value = data.map((item) => ({
url: item.url,
name: item.name
}))
}
emit('update:modelValue', value)
}
//
const upload = (files: FileData[]): Promise<void> => {
const len = files.length
let index = 0
let count = 0
return new Promise((resolve) => {
const run = async () => {
const cur = index++
const fileItem = files[cur]
const currentIndex = filesLists.value.findIndex(
(item) => item.path === fileItem.path
)
try {
const fileType: any = props.fileType === 'file' ? 'docs' : props.fileType
const { path }: any = await uploadFile(
fileType,
{
filePath: fileItem.url,
formData: props.data,
header: props.header
},
(progress) => {
filesLists.value[currentIndex].progress = progress
}
)
filesLists.value[currentIndex].status = 'success'
filesLists.value[currentIndex].url = path
} catch (error) {
filesLists.value[currentIndex].errMsg = error as string
filesLists.value[currentIndex].status = 'error'
}
count++
if (count === len) {
resolve()
return
}
if (index < len) {
run()
}
}
for (let i = 0; i < Math.min(len, props.maxCount); i++) {
run()
}
})
}
const removeFile = (url: string) => {
const index = filesLists.value.findIndex((item) => item.url === url)
if (index > -1) {
filesLists.value.splice(index, 1)
emitUpdateValue()
}
}
const retry = async (item: any) => {
item.status = 'ready'
item.progress = 0
await upload([{ ...item }])
}
const getBtnText = computed(() => {
switch (props.fileType) {
case 'image':
return '上传图片'
case 'video':
return '上传视频'
default:
return '上传文件'
}
})
const setValueItem = (item: any) => {
if (!item.url) return
const isInFiles = filesLists.value.some((file) => file.url == item.url)
if (!isInFiles) {
if (!item.name) {
item.name = getFileName(item.url)
}
item.status = 'success'
filesLists.value.push({ ...item })
}
}
watch(
() => props.modelValue,
(newVal) => {
if (Array.isArray(newVal)) {
newVal.forEach((item: any) => {
setValueItem(item)
})
} else {
if (!newVal.url) {
filesLists.value = []
}
setValueItem(newVal)
}
},
{
immediate: true
}
)
const clear = () => {
filesLists.value = []
}
defineExpose({
clear
})
</script>
<style lang="scss">
.file-upload {
// display: flex;
&.file-upload--line {
display: flex;
align-items: center;
.file-list {
.file-item {
margin-top: 0;
}
}
}
.file-picker {
display: flex;
margin-right: 20rpx;
flex: none;
.file-button {
display: flex;
align-items: center;
padding: 15rpx 20rpx;
border-radius: 10rpx;
background: #fff;
box-shadow: 0 0 10px #e6e9ed;
@apply text-primary text-xs;
}
}
.file-list {
flex: 1;
min-width: 0;
.file-item {
padding: 15rpx 20rpx;
border-radius: 10rpx;
background: #f7f7f7;
font-size: 26rpx;
display: flex;
align-items: center;
margin-top: 15rpx;
overflow: hidden;
}
}
}
</style>

View File

@ -0,0 +1,286 @@
<template>
<!-- #ifndef MP-WEIXIN -->
<view class="floating-menu" @touchstart.stop @touchend.stop @touchmove.stop>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN -->
<view class="floating-menu">
<!-- #endif -->
<movable-area class="movable-area" :scale-area="false">
<movable-view
class="movable-view"
:class="!menuData.isRemove ? 'animation-info' : ''"
style="pointer-events: auto"
@touchstart="touchstart"
@touchend="touchend"
@change="onChange"
direction="all"
inertia="true"
:x="menuData.x"
:y="menuData.y"
:disabled="disabled"
:out-of-bounds="true"
:damping="200"
:friction="100"
>
<view class="item-main" @click="openMenu">
<u-icon :name="UnfoldIcon" size="36" />
</view>
<view
v-if="menuData.showBtn"
class="menu-box"
:class="menuData.isLeft ? 'leftOut1' : 'rightOut1'"
>
<view
v-for="(item, index) in list"
:key="index"
@click="onJump(item)"
class="item-main"
>
<u-icon :name="item.icon" size="36" />
</view>
</view>
</movable-view>
</movable-area>
</view>
</template>
<script lang="ts" setup>
import { getCurrentInstance, nextTick, onMounted, reactive } from 'vue'
import HomeIcon from '@/static/images/floating_menu/home.png'
import UserIcon from '@/static/images/floating_menu/user.png'
import BackIcon from '@/static/images/floating_menu/back.png'
import UnfoldIcon from '@/static/images/floating_menu/unfold.png'
import router from '@/router'
const props = withDefaults(
defineProps<{
list?: any
disabled?: boolean
bottom?: number
right?: number
}>(),
{
list: [
{
icon: HomeIcon,
pages: '/pages/index/index'
},
{
icon: UserIcon,
pages: '/pages/user/user'
},
{
icon: BackIcon,
pages: null
}
],
disabled: false,
bottom: 160,
right: 10
}
)
const menuData = reactive({
left: 0,
top: 0,
isRemove: true,
windowWidth: 0,
windowHeight: 0,
btnWidth: 0,
btnHeight: 0,
x: 10000,
y: 10000,
old: {
x: 0,
y: 0
},
showBtn: false,
isLeft: false
})
const ctx = getCurrentInstance()
onMounted(async () => {
await nextTick()
const sysInfo = uni.getSystemInfoSync()
menuData.windowWidth = sysInfo.windowWidth
menuData.windowHeight = sysInfo.windowHeight
try {
uni.createSelectorQuery()
.in(ctx)
.select('.movable-view')
.boundingClientRect((rect: any) => {
menuData.btnWidth = rect.width
menuData.btnHeight = rect.height
menuData.x = menuData.old.x
menuData.y = menuData.old.y
nextTick(() => {
menuData.x =
menuData.windowWidth - menuData.btnWidth - props.right
menuData.y =
menuData.windowHeight - menuData.btnHeight - props.bottom
})
})
.exec()
} catch (e) {
console.log(e)
}
})
//
const onChange = (e: any) => {
menuData.old.x = e.detail.x
menuData.old.y = e.detail.y
}
//
const touchstart = (e: any) => {
menuData.isRemove = true
}
//
const touchend = (e: any) => {
if (!props.disabled && menuData.old.x) {
menuData.x = menuData.old.x
menuData.y = menuData.old.y
const bWidth = (menuData.windowWidth - menuData.btnWidth) / 2
if (menuData.x < 0 || (menuData.x > 0 && menuData.x <= bWidth)) {
nextTick(() => {
// = 10
menuData.x = 10
menuData.isLeft = true
})
} else {
nextTick(() => {
// - 10
menuData.x = menuData.windowWidth - menuData.btnWidth - 10
menuData.isLeft = false
})
}
menuData.isRemove = false
}
}
const openMenu = () => {
menuData.showBtn = !menuData.showBtn
}
//
const onJump = (item: any) => {
if (item.pages) {
router.switchTab(item.pages)
} else {
uni.navigateBack({
delta: 1,
fail: () => {
uni.$u.toast('已经是第一个页面了')
}
})
}
}
</script>
<style scoped>
.movable-view {
width: 90rpx;
height: 90rpx;
background: white;
box-shadow: 0 4rpx 12rpx 0 rgba(0, 0, 0, 0.3);
border-radius: 50rpx;
align-items: center;
justify-content: center;
position: relative;
}
.menu-box {
position: absolute;
top: 0;
display: flex;
width: 272rpx;
height: 90rpx;
padding: 0 20rpx;
background: white;
box-shadow: 0 4rpx 12rpx 0 rgba(0, 0, 0, 0.3);
border-radius: 50rpx;
}
.leftOut1 {
left: 110rpx;
animation: leftOut 0.4s;
}
.rightOut1 {
right: 110rpx;
animation: rightOut 0.4s;
}
.item-main {
width: 90rpx;
height: 90rpx;
display: flex;
align-items: center;
justify-content: center;
}
@keyframes leftOut {
0% {
opacity: 0;
left: 50rpx;
}
25% {
opacity: 0;
left: 50rpx;
}
50% {
opacity: 0.5;
left: 110rpx;
}
75% {
opacity: 1;
left: 100rpx;
}
100% {
opacity: 1;
left: 110rpx;
}
}
@keyframes rightOut {
0% {
opacity: 0;
right: 50rpx;
}
25% {
opacity: 0;
right: 50rpx;
}
50% {
opacity: 0.5;
right: 110rpx;
}
75% {
opacity: 1;
right: 100rpx;
}
100% {
opacity: 1;
right: 110rpx;
}
}
.movable-area {
width: 100%;
height: 100%;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 999999 !important;
pointer-events: none;
}
</style>

View File

@ -0,0 +1,94 @@
<template>
<u-popup v-model="showPopup" mode="center" closeable border-radius="14">
<view class="w-[670rpx] min-h-[500rpx]">
<view class="p-[28rpx] text-center font-medium text-xl"> 对话余额不足 </view>
<view class="border-t border-solid border-light border-0 px-[40rpx] py-[30rpx]">
<view>你可以通过以下渠道获取对话条数</view>
<view
class="mt-[40rpx] flex items-center"
v-for="(item, index) in channelList"
:key="index"
>
<view class="mr-[20rpx] font-medium">
{{ item.title }}
</view>
<view class="ml-auto">
<u-button
type="primary"
shape="circle"
size="medium"
:customStyle="{
padding: '0 24rpx',
height: '56rpx'
}"
@click="jump(item.path)"
>
{{ item.btnText }}
</u-button>
</view>
</view>
</view>
</view>
</u-popup>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue'
import { useRouter } from 'uniapp-router-next'
import { useAppStore } from '@/stores/app'
import { getTask } from '@/api/task'
import { onLoad } from '@dcloudio/uni-app'
const showPopup = ref(false)
const open = () => {
showPopup.value = true
}
const close = () => {
showPopup.value = false
}
const router = useRouter()
const appStore = useAppStore()
const jump = (path: string) => {
close()
router.navigateTo(path)
}
const taskList = ref([])
const getTaskList = async () => {
taskList.value = await getTask()
console.log(taskList.value)
}
const channelList = computed(() => {
const data = [
{
title: '免费获取任务奖励',
btnText: '前往分享',
path: '/packages/pages/task_center/task_center',
show: taskList?.value?.length > 0
},
{
title: '余额充值',
btnText: '前往充值',
path: '/packages/pages/recharge/recharge',
show: appStore.getIsShowRecharge
},
{
title: '开通会员',
btnText: '开通会员',
path: '/packages/pages/open_vip/open_vip',
show: appStore.getIsShowVip
}
]
return data.filter((item) => item.show)
})
onLoad(() => {
getTaskList()
})
defineExpose({
open,
close
})
</script>

View File

@ -0,0 +1,90 @@
<template>
<view class="l-textarea" :data-error="error" :class="{ error: error }">
<textarea
class="l-textarea__inner"
placeholder-class="l-textarea__placeholder"
:placeholder="placeholder"
:rows="rows"
v-model="textAreaValue"
:maxlength="maxlength"
:class="{
'!border-primary': isFocus
}"
:style="customClass"
@input="getTextAreaValue"
@focus="isFocus = true"
@blur="isFocus = false"
/>
<view v-if="showWordLimit" class="l-textarea-length-maxlength">
<text>{{ lengthText }}</text> / <text>{{ maxlength }}</text>
</view>
<slot name="length-suffix"></slot>
</view>
</template>
<script setup>
import { watch, ref, nextTick } from 'vue'
const props = defineProps({
placeholder: '',
maxlength: '',
showWordLimit: false,
customClass: {},
error: '',
rows: '',
modelValue: ''
})
const emit = defineEmits(['update:modelValue'])
const textAreaValue = ref(props.modelValue)
const lengthText = ref(props.modelValue?.length || 0)
const isFocus = ref(false)
const getTextAreaValue = (e) => {
const event = e || window.event
const target = event.detail || event.taget
emit('update:modelValue', target.value)
}
watch(
() => props.modelValue,
(val) => {
textAreaValue.value = val
lengthText.value = val.length
}
)
</script>
<style lang="scss" scoped>
.l-textarea {
width: 100%;
position: relative;
}
.l-textarea-length-maxlength {
position: absolute;
z-index: 10;
bottom: 10rpx;
right: 20rpx;
display: flex;
font-size: 24rpx;
color: #aeaeae;
@apply bg-page-base;
}
.l-textarea__inner {
width: 100%;
padding: 8px;
box-sizing: border-box;
transition: border 0.3s;
outline: none;
border-radius: 8rpx;
border: 1px solid transparent;
/** 禁止textarea拉伸 */
resize: none;
@apply bg-page-base;
}
.l-textarea__placeholder {
color: #9CA3AF;
font-size: 28rpx;
}
</style>

View File

@ -0,0 +1,66 @@
<template>
<view class="loading">
<view class="dot flex items-center">
<view class="dot-item"></view>
<view class="dot-item"></view>
<view class="dot-item"></view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Color from 'color'
const props = withDefaults(
defineProps<{
size?: string
color?: string
alpha?: number
}>(),
{
size: '22rpx',
color: '#ffffff',
alpha: 0.3
}
)
const lightColor = computed(() => {
return Color(props.color)!.alpha(props.alpha).rgbaString() || '#fff'
})
</script>
<style lang="scss" scoped>
@keyframes dot-loading {
0% {
background-color: v-bind(color);
}
50% {
background: v-bind(color);
opacity: 0.7;
}
100% {
background: v-bind(lightColor);
}
}
.dot {
.dot-item {
width: v-bind(size);
height: v-bind(size);
border-radius: 50%;
animation: dot-loading 1s linear infinite;
&:nth-child(1) {
background: v-bind(color);
}
&:nth-child(2) {
background: v-bind(color);
opacity: 0.7;
margin-left: calc(v-bind(size) / 2);
animation-delay: 0.2s;
}
&:nth-child(3) {
background: v-bind(lightColor);
margin-left: calc(v-bind(size) / 2);
animation-delay: 0.4s;
}
}
}
</style>

View File

@ -0,0 +1,285 @@
<template>
<view
v-if="userStore.isLogin && !chatModel.loading"
class="px-[20rpx] py-[15rpx] text-sm flex items-center justify-between bg-white"
>
<view class="flex-none mr-[20rpx]">
<view class="flex items-center">
<u-button
type="primary"
plain
size="medium"
:custom-style="{
background: 'transparent !important'
}"
v-if="appStore.getIsShowVip"
>
<router-navigate class="text-primary" to="/packages/pages/open_vip/open_vip">
{{
userInfo.isMember && userInfo.memberExpired !== 1
? userInfo.memberPackageName
: '开通会员'
}}
</router-navigate>
</u-button>
<view class="ml-[20rpx]" v-if="!chatModel.modelList.length">
<text
v-if="userInfo.isMember && userInfo.memberExpired !== 1"
class="flex-1 min-w-0"
>
已开通会员不消耗次数
</text>
<text v-else>
<text>消耗</text>
<text class="text-primary"> 1 </text>
<text> 条对话次数</text>
</text>
</view>
</view>
</view>
<view
v-if="chatModel.billing_is_open * 1"
@click="chatModel.show = true"
class="flex ml-auto justify-center items-center rounded-[30px] h-[60rpx]"
>
<text class="text-[#415058] mr-[6px] flex">
<text class="line-clamp-1">
{{ chatModel.current.alias }} /
<text
v-if="
chatModel?.current?.member_free &&
chatModel?.current?.is_member
"
class="text-muted"
>
会员免费
</text>
<text v-else class="flex-1 min-w-0 text-muted">
<template v-if="chatModel?.current?.balance * 1">
<text>消耗</text>
<text class="text-primary">
{{ chatModel?.current?.balance }}
</text>
<text>条对话次数</text>
</template>
<template v-else> 免费 </template>
</text>
</text>
</text>
<u-icon name="arrow-down" size="24rpx"></u-icon>
</view>
<view v-else class="text-[#415058]">
<template v-if="chatModel?.current?.balance * 1">
<text>消耗</text>
<text class="text-primary">
{{ chatModel?.current?.balance }}
</text>
<text>条对话次数</text>
</template>
<template v-else> 免费 </template>
</view>
</view>
<u-popup
v-model="chatModel.show"
mode="bottom"
border-radius="14"
:safe-area-inset-bottom="true"
height="980rpx"
closeable
>
<view class="p-[20rpx] text-xl font-bold"> 切换模型通道 </view>
<scroll-view class="h-[890rpx] box-border" scroll-y>
<view class="pb-[200rpx] px-[30rpx]">
<view
class="model-card"
v-for="(item, index) in chatModel.modelList"
:key="item.key"
:class="{
'model-card__active': chatModel.currentIndex === index
}"
@click="chatModel.currentIndex = index"
>
<view>
<view class="flex items-center">
<view class="mr-2" v-if="item.image">
<u-image :src="item.image" width="40rpx" height="40rpx"></u-image>
</view>
<view class="text-lg font-bold">{{ item.alias || item.name }}</view>
</view>
<view v-if="item.describe" class="text-base text-muted mt-2">
{{ item.describe }}
</view>
</view>
<view class="mt-4">
<view
class="mt-[20rpx] min-h-[50rpx] flex items-center justify-between"
v-for="citem in item.model_list"
:key="citem.key"
@click="handleChoiceModel(citem)"
>
<view class="mr-[6px] flex-1 min-w-0">
{{ citem.alias }}
</view>
<view class="ml-[10rpx] flex items-center">
<view
class="text-muted mr-2"
:class="{
'!text-primary': modelKey === citem.key && chatModel.currentIndex === index
}"
>
<text
v-if="
citem.member_free &&
citem.is_member
"
>
会员免费
</text>
<text v-else>
<template v-if="citem.balance * 1">
<text>消耗</text>
<text class="text-primary">
{{ citem.balance }}
</text>
<text>条对话次数</text>
</template>
<template v-else> 免费 </template>
</text>
</view>
<view
class="text-muted ml-1 mr-[2px]"
v-if="modelKey !== citem.key || chatModel.currentIndex !== index"
>
<u-image
:src="IconUnSelect"
width="32rpx"
height="32rpx"
></u-image>
</view>
<view
class="text-primary mt-[3px]"
v-if="modelKey === citem.key && chatModel.currentIndex === index"
>
<u-icon name="checkmark-circle-fill" size="40rpx"></u-icon>
</view>
</view>
</view>
</view>
</view>
</view>
</scroll-view>
</u-popup>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { getChatModelApi } from '@/api/chat'
import { useUserStore } from '@/stores/user'
import { useAppStore } from '@/stores/app'
import { reactive, watch } from 'vue'
import { computed } from 'vue'
import IconUnSelect from '@/static/images/icon/icon_unselect.png'
import { onShow } from '@dcloudio/uni-app'
const userStore = useUserStore()
const appStore = useAppStore()
const { userInfo } = storeToRefs(userStore)
const props = defineProps<{
chatKey: any
modelKey: any
}>()
const emit = defineEmits<{
(event: 'update:chatKey', value: any): void
(event: 'update:modelKey', value: any): void
}>()
const chatKey = computed({
get() {
return props.chatKey
},
set(value) {
emit('update:chatKey', value)
}
})
const modelKey = computed({
get() {
return props.modelKey
},
set(value) {
emit('update:modelKey', value)
}
})
//
const chatModel = reactive({
billing_is_open: 0,
loading: true,
show: false,
current: {
balance: 1,
key: '',
member_free: true,
model: '',
default: false
} as any,
currentIndex: 0,
modelList: [] as any[]
})
//
const getChatModelFunc = async () => {
try {
const { billing_is_open, list: data } = await getChatModelApi()
chatModel.billing_is_open = billing_is_open
chatModel.modelList = data
const first = data.find((item: any) => item.default) || data[0]
chatModel.currentIndex = data.findIndex((item: any) => item.default) || 0
chatModel.current =
first.model_list.find((item: any) => item.default) || first.model_list[0]
} catch (error) {
console.log('获取聊天模型数据错误=>', error)
} finally {
chatModel.loading = false
}
}
onShow(() => {
getChatModelFunc()
})
//
const handleChoiceModel = (item: any) => {
chatModel.current = item
chatModel.show = false
}
watch(
() => chatModel.current,
(value) => {
console.log('选择聊天模型=>', value)
chatKey.value = value?.chat_key
modelKey.value = value?.key
}
)
</script>
<style lang="scss" scoped>
.model-card {
margin-top: 20rpx;
padding: 30rpx;
border-radius: 16rpx;
border-width: 1px;
border-style: solid;
border-color: transparent;
box-shadow: 0px 1px 6px 0px rgba(230, 233, 237, 1);
}
.model-card__active {
border-width: 1px;
border-style: solid;
@apply border-primary bg-primary-light-9;
}
</style>

View File

@ -0,0 +1,69 @@
<template>
<view
class="flex items-center text-sm"
v-if="appStore.config?.network?.network_is_open"
>
<u-switch v-model="switchModel" size="30" @change="showTips"></u-switch>
<span class="ml-[10rpx]">联网模式</span>
<view class="flex px-[10rpx]" @click="showNetworkTips">
<u-icon name="info-circle" size="30" />
</view>
</view>
</template>
<script setup lang="ts">
import { useAppStore } from '@/stores/app'
import { computed, onMounted, watch } from 'vue'
import cache from '@/utils/cache'
const props = defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
}>()
const appStore = useAppStore()
const switchModel = computed({
get() {
return props.modelValue
},
set(value) {
emit('update:modelValue', value)
}
})
const showNetworkTips = () => {
uni.showModal({
title: '温馨提示',
showCancel: false,
content: `1、开启联网模式后AI机器人将可实时获取联网数据由于网络搜索总结目前可能不太稳定有时联网搜索出来的答案可能不太准确。
2联网模式不支持连续对话请在单次提问中描述清楚问题
${
appStore.config?.network?.network_balance
? `3、开启联网后每次对话将多消耗${appStore.config?.network?.network_balance}条对话(会员不消耗,大模型内置支持联网时也不消耗条数)`
: ''
}`
})
}
const showTips = (open: boolean) => {
uni.$u.toast(open ? '联网功能已开启' : '联网功能已关闭')
}
watch(switchModel, (value) => {
cache.set('isOpenNetwork', value)
})
watch(
() => appStore.config?.network?.network_is_open,
(value) => {
if (value) {
const isOpenNetwork = cache.get('isOpenNetwork')
switchModel.value = Boolean(isOpenNetwork)
}
},
{
immediate: true
}
)
</script>
<style scoped lang="scss"></style>

View File

@ -0,0 +1 @@
<template> <u-popup v-model="showNotice" mode="center" border-radius="24" zIndex="10"> <view class="w-[90vw] px-[24rpx]"> <view class="p-[30rpx] text-lg text-center font-medium"> {{ richTextTitle }} </view> <scroll-view class="max-h-[768rpx]" scroll-y> <mp-html :content="richTextContent"></mp-html> </scroll-view> <view class="py-[30rpx] bg-white"> <button :style="{ height: '82rpx', lineHeight: '82rpx', fontSize: '30rpx', border: 'none', borderRadius: '60px', background: 'var(--color-primary)' }" @click="showNotice = false" > 我知道了 </button> </view> </view> </u-popup> </template> <script lang="ts" setup> import { ref, computed, watch } from 'vue' import { useAppStore } from '@/stores/app' import cache from '@/utils/cache' import { NOTICE } from '@/enums/constantEnums' const appStore = useAppStore() const showNotice = ref<boolean>(false) const richTextTitle = computed( () => appStore.getBulletinConfig.title ) const richTextContent = computed( () => appStore.getBulletinConfig.content ) const isBulletin = computed( () => appStore.getBulletinConfig.status == 1 ) const shouldShowNotice = (value: boolean) => { const lastVisitTime = cache.get(NOTICE) const currentTime = new Date().toDateString() const isNewDay = !lastVisitTime || lastVisitTime !== currentTime if (isNewDay && value) { cache.set(NOTICE, currentTime) } return isNewDay } watch( () => [isBulletin.value], () => { if ( isBulletin.value && shouldShowNotice(isBulletin.value) ) { showNotice.value = true } }, { deep: true, immediate: true } ) </script>

View File

@ -0,0 +1,68 @@
<template>
<view
class="page-status"
v-if="status !== PageStatusEnum['NORMAL']"
:class="{ 'page-status--fixed': fixed }"
>
<!-- Loading -->
<template v-if="status === PageStatusEnum['LOADING']">
<slot name="loading">
<u-loading :size="60" mode="flower" />
</slot>
</template>
<!-- Error -->
<template v-if="status === PageStatusEnum['ERROR']">
<slot name="error"></slot>
</template>
<!-- Empty -->
<template v-if="status === PageStatusEnum['EMPTY']">
<slot name="empty"></slot>
</template>
</view>
<template v-else>
<slot> </slot>
</template>
</template>
<script lang="ts">
export default {
options: {
virtualHost: true
}
}
</script>
<script lang="ts" setup>
import { PageStatusEnum } from '@/enums/appEnums'
const props = defineProps({
status: {
type: String,
default: PageStatusEnum['LOADING']
},
fixed: {
type: Boolean,
default: true
}
})
</script>
<style lang="scss" scoped>
.page-status {
height: 100%;
width: 100%;
min-height: 100%;
padding: 0;
flex: 1;
display: flex;
justify-content: center;
align-items: center;
background-color: #ffffff;
&--fixed {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 900;
}
}
</style>

View File

@ -0,0 +1 @@
<template> <u-popup class="pay-popup" v-model="showCheckPay" round mode="center" borderRadius="10" :maskCloseAble="false" > <view class="content bg-white w-[560rpx] p-[40rpx]"> <view class="text-2xl font-medium text-center"> 支付确认 </view> <view class="pt-[30rpx] pb-[40rpx]"> <view> 请在微信内完成支付,如果您已支付成功,请点击`已完成支付`按钮 </view> </view> <view class="flex"> <view class="flex-1 mr-[20rpx]"> <u-button shape="circle" type="primary" plain size="medium" hover-class="none" :customStyle="{ width: '100%' }" @click="queryPayResult(false)" > 重新支付 </u-button> </view> <view class="flex-1"> <u-button shape="circle" type="primary" size="medium" hover-class="none" :customStyle="{ width: '100%' }" @click="queryPayResult()" > 已完成支付 </u-button> </view> </view> </view> </u-popup> </template> <script setup lang="ts"> import { getPayResult } from '@/api/pay' import { computed } from 'vue' const props = defineProps({ show: { type: Boolean, required: true }, // 订单id orderId: { type: Number, required: true }, //订单来源 from: { type: String, required: true } }) const emit = defineEmits(['update:show', 'success', 'fail']) const showCheckPay = computed({ get() { return props.show }, set(value) { emit('update:show', value) } }) const queryPayResult = async (confirm = true) => { const res = await getPayResult({ order_id: props.orderId, from: props.from }) if (res.pay_status === 0) { if (confirm == true) { uni.$u.toast('您的订单还未支付,请重新支付') } emit('fail') // showPay.value = true // handlePayResult(PayStatusEnum.FAIL) } else { if (confirm == false) { uni.$u.toast('您的订单已经支付,请勿重新支付') } // handlePayResult(PayStatusEnum.SUCCESS) emit('success') } showCheckPay.value = false } </script>

View File

@ -0,0 +1,327 @@
<template>
<u-popup
v-model="showPay"
mode="bottom"
:safe-area-inset-bottom="safeArea"
:mask-close-able="false"
border-radius="14"
closeable
:z-index="899"
@close="handleClose"
>
<view class>
<page-status :status="popupStatus" :fixed="false">
<template #error>
<u-empty text="订单信息错误,无法查询到订单信息" mode="order"></u-empty>
</template>
<template #default>
<view class="payment w-full pb-[20rpx]">
<view class="header py-[50rpx] flex flex-col items-center">
<view class="text-[34rpx]">选择支付方式</view>
</view>
<view class="main min-h-[300rpx] mx-[20rpx]">
<view>
<view class="payway-lists">
<u-radio-group
v-model="payWay"
class="w-full"
:active-color="$theme.primaryColor"
>
<view
class="p-[20rpx] flex items-center w-full payway-item"
v-for="(item, index) in payData.list"
:key="index"
@click="selectPayWay(item.id)"
>
<u-icon
class="flex-none"
:size="48"
:name="item.icon"
></u-icon>
<view class="mx-[16rpx] flex-1">
<view class="payway-item--name flex-1">
{{ item.name }}
</view>
<view class="text-muted text-xs">{{
item.extra
}}</view>
</view>
<u-radio class="mr-[-20rpx]" :name="item.id"> </u-radio>
</view>
</u-radio-group>
</view>
</view>
</view>
<view class="submit-btn p-[20rpx] mt-[50rpx]">
<u-button
@click="handlePay"
shape="circle"
type="primary"
:loading="isLock"
:disabled="!paySetup.is_open"
>
{{ paySetup.tips }}
<price
v-if="orderAmount && paySetup.is_open"
:content="orderAmount"
mainSize="34rpx"
minorSize="34rpx"
fontWeight="500"
color="var(--color-btn-text)"
></price>
</u-button>
</view>
</view>
</template>
</page-status>
</view>
</u-popup>
<payment-check
v-model:show="showCheckPay"
:from="from"
:order-id="orderId"
@success="checkSuccess"
@fail="checkFail"
/>
</template>
<script lang="ts" setup>
import { pay, PayWayEnum } from '@/utils/pay'
import { getPayWay, prepay } from '@/api/pay'
import { computed, onMounted, ref, watch } from 'vue'
import { useLockFn } from '@/hooks/useLockFn'
import { series } from '@/utils/util'
import { ClientEnum, PageStatusEnum, PayStatusEnum } from '@/enums/appEnums'
import { useUserStore } from '@/stores/user'
import { client } from '@/utils/client'
import { useRouter } from 'uniapp-router-next'
// #ifdef H5
import wechatOa, { UrlScene } from '@/utils/wechat'
// #endif
import PaymentCheck from './check.vue'
/*
页面参数 orderId订单idfrom订单来源
*/
const props = defineProps({
show: {
type: Boolean,
required: true
},
showCheck: {
type: Boolean
},
// id
orderId: {
type: Number,
required: true
},
//
from: {
type: String,
required: true
},
//h5
redirect: {
type: String
},
orderAmount: {
type: Number
},
safeArea: {
type: Boolean,
required: true
}
})
const emit = defineEmits(['update:showCheck', 'update:show', 'close', 'success', 'fail'])
const router = useRouter()
const payWay = ref()
const popupStatus = ref(PageStatusEnum.LOADING)
const payData = ref<any>({
lists: []
})
// const system = uni.getSystemInfoSync()
const paySetup = ref({
is_open: 1,
tips: '立即支付'
})
const showCheckPay = computed({
get() {
return props.showCheck
},
set(value) {
emit('update:showCheck', value)
}
})
const showPay = computed({
get() {
return props.show
},
set(value) {
emit('update:show', value)
}
})
const handleClose = () => {
showPay.value = false
emit('close')
}
const getPayData = async () => {
popupStatus.value = PageStatusEnum.LOADING
try {
payData.value = await getPayWay({
orderId: props.orderId,
from: props.from
})
popupStatus.value = PageStatusEnum.NORMAL
const checkPay =
payData.value.list.find((item: any) => item.isDefault) || payData.value.list[0]
payWay.value = checkPay?.id
} catch (error) {
console.log('获取支付方式错误=>', error)
popupStatus.value = PageStatusEnum.ERROR
}
}
const userStore = useUserStore()
const selectPayWay = (pay: number) => {
payWay.value = pay
}
const checkIsBindWx = async () => {
if (
userStore.userInfo.isBindWechat == false &&
[ClientEnum.OA_WEIXIN, ClientEnum.MP_WEIXIN].includes(client) &&
payWay.value == PayWayEnum.WECHAT
) {
switch (client) {
case ClientEnum.OA_WEIXIN: {
wechatOa.getUrl(UrlScene.BASE, 'snsapi_base', {
id: props.orderId,
from: props.from
})
return Promise.reject()
}
case ClientEnum.MP_WEIXIN: {
const data = await uni.login()
return data.code
}
}
}
}
const payment = async (code: string | undefined) => {
//
try {
uni.showLoading({
title: '正在支付中'
})
const data = await prepay({
orderId: props.orderId,
scene: props.from,
payWay: payWay.value,
redirect: props.redirect,
code
})
console.log('支付信息', data)
const res = await pay.payment(payWay.value, data)
return res
} catch (error) {
return Promise.reject(error)
}
}
const { isLock, lockFn: handlePay } = useLockFn(async () => {
try {
const code = await checkIsBindWx()
const res: PayStatusEnum = await payment(code)
handlePayResult(res)
uni.hideLoading()
} catch (error) {
uni.hideLoading()
console.log(error)
}
})
const handlePayResult = (status: PayStatusEnum) => {
switch (status) {
case PayStatusEnum.SUCCESS:
emit('success')
break
case PayStatusEnum.FAIL:
emit('fail')
break
}
}
const checkSuccess = () => {
handlePayResult(PayStatusEnum.SUCCESS)
}
const checkFail = () => {
showPay.value = true
handlePayResult(PayStatusEnum.FAIL)
}
//
// const getPaySetup = async () => {
// try {
// paySetup.value = await getIosPayConfig()
// } catch (error) {
// console.log('=>', error)
// }
// }
watch(
() => props.show,
async (value) => {
if (value) {
if (!props.orderId) {
popupStatus.value = PageStatusEnum.ERROR
return
}
await getPayData()
}
},
{
immediate: true
}
)
onMounted(async () => {
// #ifdef H5
const options = wechatOa.getAuthData()
if (options.code && options.scene === UrlScene.BASE) {
payWay.value = PayWayEnum.WECHAT
showPay.value = true
try {
const res: PayStatusEnum = await payment(options.code)
handlePayResult(res)
uni.hideLoading()
} catch (error) {
uni.hideLoading()
console.log(error)
} finally {
wechatOa.setAuthData({})
}
}
// #endif
// #ifdef MP
// if (system.system.indexOf('iOS') !== -1) {
// await getPaySetup()
// }
// #endif
})
</script>
<style lang="scss">
.payway-lists {
.payway-item {
border-bottom: 1px solid;
@apply border-page;
}
}
</style>

View File

@ -0,0 +1,126 @@
<template>
<view class="price-container">
<view
:class="['price-wrap', { 'price-wrap--disabled': lineThrough }]"
:style="{ color: color }"
>
<!-- Prefix -->
<view class="fix-pre" :style="{ fontSize: minorSize }">
<slot name="prefix">{{ prefix }}</slot>
</view>
<!-- Content -->
<view :style="{ 'font-weight': fontWeight }">
<!-- Integer -->
<text :style="{ fontSize: mainSize }">{{ integer }}</text>
<!-- Decimals -->
<text :style="{ fontSize: minorSize }">{{ decimals }}</text>
</view>
<!-- Suffix -->
<view class="fix-suf" :style="{ fontSize: minorSize }">
<slot name="suffix">{{ suffix }}</slot>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
/**
* @description 价格展示适用于有前后缀小数样式不一
* @property {String|Number} content 价格 (必填项)
* @property {Number} prec 小数位 (默认: 2)
* @property {Boolean} autoPrec 自动小数位以prec为最大小数位 (默认: true)
* @property {String} color 颜色 (默认: 'unset')
* @property {String} mainSize 主要内容字体大小 (默认: 46rpx)
* @property {String} minorSize 主要内容字体大小 (默认: 32rpx)
* @property {Boolean} lineThrough 贯穿线 (默认: false)
* @property {String|Number} fontWeight 字重 (默认: normal)
* @property {String} prefix 前缀 (默认: )
* @property {String} suffix 后缀
* @example <price content="100" suffix="\/元" />
*/
import { computed } from 'vue'
import { formatPrice } from '@/utils/util'
/** Props Start **/
const props = withDefaults(
defineProps<{
content: string | number //
prec?: number //
autoPrec?: boolean //
color?: string //
mainSize?: string //
minorSize?: string //
lineThrough?: boolean // 穿线
fontWeight?: string //
prefix?: string //
suffix?: string //
}>(),
{
content: '',
prec: 2,
autoPrec: true,
color: '#FA8919',
mainSize: '36rpx',
minorSize: '28rpx',
lineThrough: false,
fontWeight: 'normal',
prefix: '¥',
suffix: ''
}
)
/** Props End **/
/** Computed Start **/
/**
* @description 金额主体部分
*/
const integer = computed(() => {
return formatPrice({
price: props.content,
take: 'int'
})
})
/**
* @description 金额小数部分
*/
const decimals = computed(() => {
let decimals = formatPrice({
price: props.content,
take: 'dec',
prec: props.prec
})
// .10||.20||.30
decimals = decimals % 10 == 0 ? decimals.substr(0, decimals.length - 1) : decimals
return props.autoPrec ? (decimals * 1 ? '.' + decimals : '') : props.prec ? '.' + decimals : ''
})
/** Computed End **/
</script>
<style lang="scss" scoped>
.price-container {
display: inline-block;
}
.price-wrap {
display: flex;
align-items: baseline;
&--disabled {
position: relative;
&::before {
position: absolute;
left: 0;
top: 50%;
right: 0;
transform: translateY(-50%);
display: block;
content: '';
height: 0.05em;
background-color: currentColor;
}
}
}
</style>

View File

@ -0,0 +1,293 @@
<template>
<u-popup
v-model="showModel"
mode="bottom"
safe-area-inset-bottom
:mask="false"
height="100%"
:custom-style="{
background:
'linear-gradient(180deg,rgba(18,19,23,.08),rgba(18,19,23,.98) 96%)'
}"
>
<view
class="h-full flex flex-col-reverse recorder"
@touchend="stopRecord"
@touchcancel="stopRecord"
>
<view class="px-[60rpx] pb-[140rpx]">
<view class="px-[60rpx]">
<view class="bubble-text relative">
<u-input
class="flex-1"
v-show="userInput || (!isTransfer && !isRecording)"
type="textarea"
v-model="userInput"
placeholder="请输入您的问题"
maxlength="-1"
:cursor-spacing="120"
:auto-height="true"
confirm-type="send"
:fixed="true"
/>
<view class="wave" v-if="!userInput && isRecording">
<view
class="wave-item"
v-for="(item, index) in 10"
:key="item"
:style="{
'--delay': `${index / 10}s`
}"
>
</view>
</view>
<view v-if="isTransfer" class="loading">
<u-loading
mode="flower"
color="#fff"
:size="40"
></u-loading>
</view>
</view>
</view>
<view class="text-white mt-[40rpx] text-center text-xs">
<template v-if="isTransfer"> 文字转换中... </template>
<template v-else>
{{
isRecording
? '松开手指,转换文字'
: '点击气泡可编辑文字'
}}
</template>
</view>
<view class="flex justify-between text-white">
<view
class="action-btn bg-content"
@click="showModel = false"
>
<u-icon name="close" :size="32"></u-icon>
</view>
<view
class="action-btn bg-success"
@click="emit('success', userInput)"
>
<u-icon name="checkmark" :size="34"></u-icon>
</view>
</view>
<view class="flex justify-center">
<view class="relative flex justify-center items-center">
<view
class="w-[170rpx] h-[170rpx] rounded-[50%] bg-primary opacity-30 absolute"
>
</view>
<button
class="flex justify-center items-center w-[130rpx] h-[130rpx] rounded-[50%] bg-primary text-btn-text relative z-10"
@longpress="startRecord"
hover-class="none"
>
<!-- 添加空格的目的是touchend事件有时候不触发 -->
&nbsp;
<u-icon v-if="!isRecording" name="mic" :size="60" />
<loading v-else> </loading>
&nbsp;
</button>
</view>
</view>
</view>
</view>
</u-popup>
</template>
<script lang="ts">
export default {
options: {
styleIsolation: 'shared'
}
}
</script>
<script setup lang="ts">
import { audioTransfer } from '@/api/chat'
import { useRecorder } from '@/hooks/useRecorder'
import { onUnmounted } from 'vue'
import { shallowRef } from 'vue'
import { computed } from 'vue'
import { watch } from 'vue'
import { ref } from 'vue'
const props = defineProps<{
show: boolean
}>()
const emit = defineEmits<{
(event: 'update:show', show: boolean): void
(event: 'success', text: string): void
}>()
const showModel = computed({
get() {
return props.show
},
set(value) {
emit('update:show', value)
}
})
const userInput = ref('')
const isTransfer = ref(false)
const { start, isRecording, stop, authorize, close } = useRecorder({
async onstop(result) {
if (result.duration < 1000) {
uni.$u.toast('说话时间太短')
return
}
isTransfer.value = true
try {
const data: any = await audioTransfer(result.tempFilePath, {
type: 2
})
userInput.value += data.text
} finally {
isTransfer.value = false
}
}
})
const startRecord = async () => {
if (isRecording.value || isTransfer.value) {
return
}
try {
await start()
} catch (error) {
uni.$u.toast(error)
}
}
const stopRecord = () => {
if (isRecording.value) {
stop()
}
}
watch(showModel, (value) => {
if (!value) {
stopRecord()
} else {
userInput.value = ''
// startRecord()
}
})
defineExpose({
authorize: authorize,
startRecord,
stopRecord,
closeRecord: close
})
</script>
<style lang="scss" scoped>
@keyframes dot-loading {
0% {
background-color: #fff;
}
50% {
background: #fff;
opacity: 0.6;
}
100% {
background: rgba(256, 256, 256, 0.3);
}
}
@keyframes wave {
0%,
40%,
100% {
transform: scaleY(1);
}
20% {
transform: scaleY(0.4);
}
}
.recorder {
.bubble-text {
position: relative;
padding: 30rpx;
border-radius: 24rpx;
display: flex;
justify-content: center;
align-items: center;
min-height: 120rpx;
@apply bg-primary;
&::before {
content: ' ';
width: 10px;
height: 10px;
position: absolute;
left: 50%;
bottom: -5px;
-webkit-transform: translateX(-50%) rotate(45deg);
transform: translateX(-50%) rotate(45deg);
@apply bg-primary;
}
:deep() {
.u-input__textarea {
--line-height: 40rpx;
--line-num: 6;
height: auto;
min-height: var(--line-height) !important;
max-height: calc(var(--line-height) * var(--line-num));
font-size: 28rpx;
box-sizing: border-box;
padding: 0;
line-height: var(--line-height);
@apply text-white;
.uni-textarea-textarea {
max-height: calc(var(--line-height) * var(--line-num));
overflow-y: auto !important;
}
}
}
}
.dot {
.dot-item {
width: 20rpx;
height: 20rpx;
border-radius: 50%;
animation: dot-loading 1s linear infinite;
&:nth-child(1) {
background: #fff;
}
&:nth-child(2) {
background: #fff;
opacity: 0.6;
margin-left: 6px;
animation-delay: 0.2s;
}
&:nth-child(3) {
background: rgba(256, 256, 256, 0.3);
margin-left: 6px;
animation-delay: 0.4s;
}
}
}
.wave {
display: flex;
.wave-item {
display: inline-block;
width: 6rpx;
height: 30rpx;
background-color: #fff;
margin: 0 5rpx;
border-radius: 2rpx;
animation: wave 1.2s infinite ease-in-out;
animation-delay: var(--delay);
}
}
.action-btn {
width: 76rpx;
height: 76rpx;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
}
}
</style>

View File

@ -0,0 +1,92 @@
<template>
<u-tabbar
v-if="showTabbar"
v-model="current"
v-bind="tabbarStyle"
:list="tabbarList"
@change="handleChange"
:hide-tab-bar="true"
></u-tabbar>
</template>
<script lang="ts" setup>
import { useAppStore } from '@/stores/app'
import { useNavigationBarTitleStore } from '@/stores/navigationBarTitle'
import { navigateTo } from '@/utils/util'
import { computed, ref, watch } from 'vue'
const current = ref()
const appStore = useAppStore()
const navigationBarTitleStore = useNavigationBarTitleStore()
const tabbarList = computed(() => {
return (
appStore.getTabbarConfig.list
?.filter((item: any) => item.is_show == '1')
.map((item: any) => {
const link = item.link
return {
iconPath: appStore.getImageUrl(item.unselected),
selectedIconPath: appStore.getImageUrl(item.selected),
text: item.name,
link,
pagePath: link.path
}
}) || []
)
})
//
const nativeTabList = [
'/pages/index/index',
'/pages/ai_creation/ai_creation',
'/pages/skills/skills',
'/pages/app/app',
'/pages/user/user'
]
const getCurrentIndex = () => {
const currentPages = getCurrentPages()
const currentPage = currentPages[currentPages.length - 1]
const current = tabbarList.value.findIndex((item: any) => {
return item.pagePath === '/' + currentPage.route
})
return current
}
const showTabbar = computed(() => {
const currentPages = getCurrentPages()
const currentPage = currentPages[currentPages.length - 1]
const current = tabbarList.value.findIndex((item: any) => {
return item.pagePath === '/' + currentPage.route
})
return current >= 0
})
const tabbarStyle = computed(() => ({
activeColor: appStore.getTabbarConfig?.style?.selected_color,
inactiveColor: appStore.getTabbarConfig?.style?.default_color
}))
const handleChange = (index: number) => {
const selectTab = tabbarList.value[index]
selectTab.link.name = selectTab.text
const navigateType = nativeTabList.includes(selectTab.pagePath)
? 'switchTab'
: 'reLaunch'
navigateTo(selectTab.link, false, navigateType)
}
watch(
tabbarList,
() => {
const current = getCurrentIndex()
if (current >= 0) {
const currentTab = tabbarList.value[current]
navigationBarTitleStore.add({
path: currentTab.pagePath,
title: currentTab.text
})
navigationBarTitleStore.setTitle()
}
},
{
immediate: true
}
)
</script>

View File

@ -0,0 +1,367 @@
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: inherit;
font-weight: 500;
line-height: 1.1;
color: inherit;
}
h1,
h2,
h3 {
margin-top: 20px;
margin-bottom: 10px;
}
h4,
h5,
h6 {
margin-top: 10px;
margin-bottom: 10px}
.h1,
h1 {
font-size: 36px;
}
.h2,
h2 {
font-size: 30px;
}
.h3,
h3 {
font-size: 24px;
}
.h4,
h4 {
font-size: px;
}
.h5,
h5 {
font-size: 14px;
}
.h6,
h6 {
font-size: 12px;
}
a {
background-color: transparent;
color: #2196f3;
text-decoration: none;
}
hr,
::v-deep .hr {
margin-top: 20px;
margin-bottom: 20px;
border: 0;
border-top: 1px solid #e5e5e5;
}
img, ._img {
margin-bottom: 10px;
margin-right: 10px;
max-width: 100%;
}
p {
margin: 0 0 10px;
}
em {
font-style: italic;
font-weight: inherit;
}
ol,
ul {
margin-top: 0;
margin-bottom: 10px;
padding-left: 40px;
}
ol ol,
ol ul,
ul ol,
ul ul {
margin-bottom: 0;
}
ol ol,
ul ol {
list-style-type: lower-roman;
}
ol ol ol,
ul ul ol {
list-style-type: lower-alpha;
}
dl {
margin-top: 0;
margin-bottom: 20px;
}
dt {
font-weight: 600;
}
dt,
dd {
line-height: 1.4;
}
.task-list-item {
list-style-type: none;
}
.task-list-item input {
margin: 0 0.2em 0.25em -1.6em;
vertical-align: middle;
}
pre {
position: relative;
z-index: 11;
}
codekbd,
pre,
samp {
font-family: Menlo, Monaco, Consolas, 'Courier New', monospace;
}
code:not(.hljs) {
padding: 2px 4px;
border-radius: 4px;
}
code:empty {
display: none;
}
pre code.hljs {
color: var(--vg__text-1);
border-radius: 16px;
background: var(--vg__bg-1);
font-size: 12px;
}
.markdown-wrap {
font-size: 12px;
margin-bottom: 10px;
background-color: #24272e;
}
._a {
color: #2196f3;
}
.copy-line {
background-color: #595b63;
color: #e0e0e0;
height: 38px;
line-height: 32px;
font-size: 12px;
box-sizing: border-box;
padding: 0 12px;
display: flex;
}
.copy-line .code-copy-btn {
color: #e0e0e0;
cursor: pointer;
margin-left: auto;
}
pre.code-block-wrapper {
background: #2b2b2b;
color: #f8f8f2;
border-radius: 4px;
overflow-x: auto;
padding: 1em;
position: relative;
}
pre.code-block-wrapper code {
padding: auto;
font-size: inherit;
color: inherit;
background-color: inherit;
border-radius: 0;
}
.code-block-header__copy {
font-size: 16px;
margin-left: 5px;
}
abbr[data-original-title],
abbr[title] {
cursor: help;
border-bottom: 1px dotted #777;
}
blockquote {
padding: 10px 20px;
margin: 0 0 20px;
font-size: 17.5px;
border-left: 5px solid #e5e5e5;
}
blockquote ol:last-child,
blockquote p:last-child,
blockquote ul:last-child {
margin-bottom: 0;
}
blockquote .small,
blockquote footer,
blockquote small {
display: block;
font-size: 80%;
line-height: 1.42857143;
color: #777;
}
blockquote .small:before,
blockquote footer:before,
blockquote small:before {
content: '\2014 \00A0';
}
.blockquote-reverse,
blockquote.pull-right {
padding-right: 15px;
padding-left: 0;
text-align: right;
border-right: 5px solid #eee;
border-left: 0;
}
.blockquote-reverse .small:before,
.blockquote-reverse footer:before,
.blockquote-reverse small:before,
blockquote.pull-right .small:before,
blockquote.pull-right footer:before,
blockquote.pull-right small:before {
content: '';
}
.blockquote-reverse .small:after,
.blockquote-reverse footer:after,
.blockquote-reverse small:after,
blockquote.pull-right .small:after,
blockquote.pull-right footer:after,
blockquote.pull-right small:after {
content: '\00A0 \2014';
}
.footnotes {
-moz-column-count: 2;
-webkit-column-count: 2;
column-count: 2;
}
.footnotes-list {
padding-left: 2em;
}
table,
::v-deep .table {
border-spacing: 0;
border-collapse: collapse;
width: 100%;
max-width: 65em;
overflow: auto;
margin-top: 0;
margin-bottom: 16px;
}
table tr,
::v-deep .table .tr {
border-top: 1px solid #e5e5e5;
}
table th,
table td,
::v-deep .table .th,
::v-deep .table .td {
padding: 6px 13px;
border: 1px solid #e5e5e5;
}
table th,
::v-deep .table .th {
font-weight: 600;
background-color: #eee;
}
.hljs[class*='language-']:before {
position: absolute;
z-index: 3;
top: 0.8em;
right: 1em;
font-size: 0.8em;
color: #999;
}
.hljs[class~='language-js']:before {
content: 'js';
}
.hljs[class~='language-ts']:before {
content: 'ts';
}
.hljs[class~='language-html']:before {
content: 'html';
}
.hljs[class~='language-md']:before {
content: 'md';
}
.hljs[class~='language-vue']:before {
content: 'vue';
}
.hljs[class~='language-css']:before {
content: 'css';
}
.hljs[class~='language-sass']:before {
content: 'sass';
}
.hljs[class~='language-scss']:before {
content: 'scss';
}
.hljs[class~='language-less']:before {
content: 'less';
}
.hljs[class~='language-stylus']:before {
content: 'stylus';
}
.hljs[class~='language-go']:before {
content: 'go';
}
.hljs[class~='language-java']:before {
content: 'java';
}
.hljs[class~='language-c']:before {
content: 'c';
}
.hljs[class~='language-sh']:before {
content: 'sh';
}
.hljs[class~='language-yaml']:before {
content: 'yaml';
}
.hljs[class~='language-py']:before {
content: 'py';
}
.hljs[class~='language-docker']:before {
content: 'docker';
}
.hljs[class~='language-dockerfile']:before {
content: 'dockerfile';
}
.hljs[class~='language-makefile']:before {
content: 'makefile';
}
.hljs[class~='language-javascript']:before {
content: 'js';
}
.hljs[class~='language-typescript']:before {
content: 'ts';
}
.hljs[class~='language-markup']:before {
content: 'html';
}
.hljs[class~='language-markdown']:before {
content: 'md';
}
.hljs[class~='language-json']:before {
content: 'json';
}
.hljs[class~='language-ruby']:before {
content: 'rb';
}
.hljs[class~='language-python']:before {
content: 'py';
}
.hljs[class~='language-bash']:before {
content: 'sh';
}
.hljs[class~='language-php']:before {
content: 'php';
}
._abbr,
._b,
._code,
._del,
._em,
._i,
._ins,
._label,
._q,
._span,
._strong,
._sub,
._sup {
display: inline;
}

View File

@ -0,0 +1 @@
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#abb2bf;background:#282c34}.hljs-comment,.hljs-quote{color:#5c6370;font-style:italic}.hljs-doctag,.hljs-formula,.hljs-keyword{color:#c678dd}.hljs-deletion,.hljs-name,.hljs-section,.hljs-selector-tag,.hljs-subst{color:#e06c75}.hljs-literal{color:#56b6c2}.hljs-addition,.hljs-attribute,.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#98c379}.hljs-attr,.hljs-number,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-pseudo,.hljs-template-variable,.hljs-type,.hljs-variable{color:#d19a66}.hljs-bullet,.hljs-link,.hljs-meta,.hljs-selector-id,.hljs-symbol,.hljs-title{color:#61aeee}.hljs-built_in,.hljs-class .hljs-title,.hljs-title.class_{color:#e6c07b}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}.hljs-link{text-decoration:underline}

View File

@ -0,0 +1 @@
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#383a42;background:#fafafa}.hljs-comment,.hljs-quote{color:#a0a1a7;font-style:italic}.hljs-doctag,.hljs-formula,.hljs-keyword{color:#a626a4}.hljs-deletion,.hljs-name,.hljs-section,.hljs-selector-tag,.hljs-subst{color:#e45649}.hljs-literal{color:#0184bb}.hljs-addition,.hljs-attribute,.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#50a14f}.hljs-attr,.hljs-number,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-pseudo,.hljs-template-variable,.hljs-type,.hljs-variable{color:#986801}.hljs-bullet,.hljs-link,.hljs-meta,.hljs-selector-id,.hljs-symbol,.hljs-title{color:#4078f2}.hljs-built_in,.hljs-class .hljs-title,.hljs-title.class_{color:#c18401}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}.hljs-link{text-decoration:underline}

View File

@ -0,0 +1,10 @@
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*!
Theme: GitHub Dark
Description: Dark theme as seen on github.com
Author: github.com
Maintainer: @Hirse
Updated: 2021-05-15
Outdated base version: https://github.com/primer/github-syntax-dark
Current colors taken from GitHub's CSS
*/.hljs{color:#c9d1d9;background:#0d1117}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#79c0ff}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-code,.hljs-comment,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{color:#aff5b4;background-color:#033a16}.hljs-deletion{color:#ffdcd7;background-color:#67060c}

View File

@ -0,0 +1,352 @@
/*
* HTML5 Parser By Sam Blowes
*
* Designed for HTML5 documents
*
* Original code by John Resig (ejohn.org)
* http://ejohn.org/blog/pure-javascript-html-parser/
* Original code by Erik Arvidsson, Mozilla Public License
* http://erik.eae.net/simplehtmlparser/simplehtmlparser.js
*
* ----------------------------------------------------------------------------
* License
* ----------------------------------------------------------------------------
*
* This code is triple licensed using Apache Software License 2.0,
* Mozilla Public License or GNU Public License
*
* ////////////////////////////////////////////////////////////////////////////
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* ////////////////////////////////////////////////////////////////////////////
*
* The contents of this file are subject to the Mozilla Public License
* Version 1.1 (the "License"); you may not use this file except in
* compliance with the License. You may obtain a copy of the License at
* http://www.mozilla.org/MPL/
*
* Software distributed under the License is distributed on an "AS IS"
* basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the
* License for the specific language governing rights and limitations
* under the License.
*
* The Original Code is Simple HTML Parser.
*
* The Initial Developer of the Original Code is Erik Arvidsson.
* Portions created by Erik Arvidssson are Copyright (C) 2004. All Rights
* Reserved.
*
* ////////////////////////////////////////////////////////////////////////////
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*
* ----------------------------------------------------------------------------
* Usage
* ----------------------------------------------------------------------------
*
* // Use like so:
* HTMLParser(htmlString, {
* start: function(tag, attrs, unary) {},
* end: function(tag) {},
* chars: function(text) {},
* comment: function(text) {}
* });
*
* // or to get an XML string:
* HTMLtoXML(htmlString);
*
* // or to get an XML DOM Document
* HTMLtoDOM(htmlString);
*
* // or to inject into an existing document/DOM node
* HTMLtoDOM(htmlString, document);
* HTMLtoDOM(htmlString, document.body);
*
*/
// Regular Expressions for parsing tags and attributes
var startTag = /^<([-A-Za-z0-9_]+)((?:\s+[a-zA-Z_:][-a-zA-Z0-9_:.]*(?:\s*=\s*(?:(?:"[^"]*")|(?:'[^']*')|[^>\s]+))?)*)\s*(\/?)>/;
var endTag = /^<\/([-A-Za-z0-9_]+)[^>]*>/;
var attr = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:(?:"((?:\\.|[^"])*)")|(?:'((?:\\.|[^'])*)')|([^>\s]+)))?/g; // Empty Elements - HTML 5
var empty = makeMap('area,base,basefont,br,col,frame,hr,img,input,link,meta,param,embed,command,keygen,source,track,wbr'); // Block Elements - HTML 5
// fixed by xxx 将 ins 标签从块级名单中移除
var block = makeMap('a,address,article,applet,aside,audio,blockquote,button,canvas,center,dd,del,dir,div,dl,dt,fieldset,figcaption,figure,footer,form,frameset,h1,h2,h3,h4,h5,h6,header,hgroup,hr,iframe,isindex,li,map,menu,noframes,noscript,object,ol,output,p,pre,section,script,table,tbody,td,tfoot,th,thead,tr,ul,video'); // Inline Elements - HTML 5
var inline = makeMap('abbr,acronym,applet,b,basefont,bdo,big,br,button,cite,code,del,dfn,em,font,i,iframe,img,input,ins,kbd,label,map,object,q,s,samp,script,select,small,span,strike,strong,sub,sup,textarea,tt,u,var'); // Elements that you can, intentionally, leave open
// (and which close themselves)
var closeSelf = makeMap('colgroup,dd,dt,li,options,p,td,tfoot,th,thead,tr'); // Attributes that have their values filled in disabled="disabled"
var fillAttrs = makeMap('checked,compact,declare,defer,disabled,ismap,multiple,nohref,noresize,noshade,nowrap,readonly,selected'); // Special Elements (can contain anything)
var special = makeMap('script,style');
function HTMLParser(html, handler) {
var index;
var chars;
var match;
var stack = [];
var last = html;
stack.last = function () {
return this[this.length - 1];
};
while (html) {
chars = true; // Make sure we're not in a script or style element
if (!stack.last() || !special[stack.last()]) {
// Comment
if (html.indexOf('<!--') == 0) {
index = html.indexOf('-->');
if (index >= 0) {
if (handler.comment) {
handler.comment(html.substring(4, index));
}
html = html.substring(index + 3);
chars = false;
} // end tag
} else if (html.indexOf('</') == 0) {
match = html.match(endTag);
if (match) {
html = html.substring(match[0].length);
match[0].replace(endTag, parseEndTag);
chars = false;
} // start tag
} else if (html.indexOf('<') == 0) {
match = html.match(startTag);
if (match) {
html = html.substring(match[0].length);
match[0].replace(startTag, parseStartTag);
chars = false;
}
}
if (chars) {
index = html.indexOf('<');
var text = index < 0 ? html : html.substring(0, index);
html = index < 0 ? '' : html.substring(index);
if (handler.chars) {
handler.chars(text);
}
}
} else {
html = html.replace(new RegExp('([\\s\\S]*?)<\/' + stack.last() + '[^>]*>'), function (all, text) {
text = text.replace(/<!--([\s\S]*?)-->|<!\[CDATA\[([\s\S]*?)]]>/g, '$1$2');
if (handler.chars) {
handler.chars(text);
}
return '';
});
parseEndTag('', stack.last());
}
if (html == last) {
throw 'Parse Error: ' + html;
}
last = html;
} // Clean up any remaining tags
parseEndTag();
function parseStartTag(tag, tagName, rest, unary) {
tagName = tagName.toLowerCase();
if (block[tagName]) {
while (stack.last() && inline[stack.last()]) {
parseEndTag('', stack.last());
}
}
if (closeSelf[tagName] && stack.last() == tagName) {
parseEndTag('', tagName);
}
unary = empty[tagName] || !!unary;
if (!unary) {
stack.push(tagName);
}
if (handler.start) {
var attrs = [];
rest.replace(attr, function (match, name) {
var value = arguments[2] ? arguments[2] : arguments[3] ? arguments[3] : arguments[4] ? arguments[4] : fillAttrs[name] ? name : '';
attrs.push({
name: name,
value: value,
escaped: value.replace(/(^|[^\\])"/g, '$1\\\"') // "
});
});
if (handler.start) {
handler.start(tagName, attrs, unary);
}
}
}
function parseEndTag(tag, tagName) {
// If no tag name is provided, clean shop
if (!tagName) {
var pos = 0;
} // Find the closest opened tag of the same type
else {
for (var pos = stack.length - 1; pos >= 0; pos--) {
if (stack[pos] == tagName) {
break;
}
}
}
if (pos >= 0) {
// Close all the open elements, up the stack
for (var i = stack.length - 1; i >= pos; i--) {
if (handler.end) {
handler.end(stack[i]);
}
} // Remove the open elements from the stack
stack.length = pos;
}
}
}
function makeMap(str) {
var obj = {};
var items = str.split(',');
for (var i = 0; i < items.length; i++) {
obj[items[i]] = true;
}
return obj;
}
function removeDOCTYPE(html) {
return html.replace(/<\?xml.*\?>\n/, '').replace(/<!doctype.*>\n/, '').replace(/<!DOCTYPE.*>\n/, '');
}
function parseAttrs(attrs) {
return attrs.reduce(function (pre, attr) {
var value = attr.value;
var name = attr.name;
if (pre[name]) {
pre[name] = pre[name] + " " + value;
} else {
pre[name] = value;
}
return pre;
}, {});
}
function parseHtml(html) {
html = removeDOCTYPE(html);
var stacks = [];
var results = {
node: 'root',
children: []
};
HTMLParser(html, {
start: function start(tag, attrs, unary) {
var node = {
name: tag
};
if (attrs.length !== 0) {
node.attrs = parseAttrs(attrs);
}
if (unary) {
var parent = stacks[0] || results;
if (!parent.children) {
parent.children = [];
}
parent.children.push(node);
} else {
stacks.unshift(node);
}
},
end: function end(tag) {
var node = stacks.shift();
if (node.name !== tag) console.error('invalid state: mismatch end tag');
if (stacks.length === 0) {
results.children.push(node);
} else {
var parent = stacks[0];
if (!parent.children) {
parent.children = [];
}
parent.children.push(node);
}
},
chars: function chars(text) {
var node = {
type: 'text',
text: text
};
if (stacks.length === 0) {
results.children.push(node);
} else {
var parent = stacks[0];
if (!parent.children) {
parent.children = [];
}
parent.children.push(node);
}
},
comment: function comment(text) {
var node = {
node: 'comment',
text: text
};
var parent = stacks[0];
if (!parent.children) {
parent.children = [];
}
parent.children.push(node);
}
});
return results.children;
}
export default parseHtml;

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,145 @@
<!-- uniapp vue3 markdown解析 -->
<template>
<view class="ua__markdown">
<!-- <rich-text-->
<!-- space="nbsp"-->
<!-- selectable-->
<!-- user-select-->
<!-- :nodes="parseNodes(content)"-->
<!-- @itemclick="handleItemClick"-->
<!-- >-->
<!-- </rich-text>-->
<mp-html
:selectable="true"
:scrollTable="true"
:content="parseNodes(content)"
@linktap="handleItemClick"
></mp-html>
</view>
</template>
<script setup>
import MarkdownIt from './lib/markdown-it.min.js'
import hljs from 'highlight.js/lib/common'
import './lib/highlight/atom-one-dark.css'
// #ifdef APP-NVUE
import parseHtml from './lib/html-parser.js'
// #endif
import markdownItMath from '@iktakahiro/markdown-it-katex'
import MpHtml from '@/uni_modules/mp-html/components/mp-html/mp-html.vue'
import { useCopy } from '@/hooks/useCopy'
const props = defineProps({
//
content: String,
showLine: { type: [Boolean, String], default: true }
})
const copyCodeData = []
const markdown = MarkdownIt({
html: true,
breaks: true,
typographer: true,
linkify: true,
lineNumbers: true,
highlight: function (str, lang) {
let preCode = ''
try {
preCode = hljs.highlightAuto(str).value
} catch (err) {
preCode = markdown.utils.escapeHtml(str)
}
const lines = preCode.split(/\n/).slice(0, -1)
//
let html = lines
.map((item, index) => {
if (item == '') {
return ''
}
return (
'<li><span class="line-num" data-line="' +
(index + 1) +
'"></span>' +
item +
'</li>'
)
})
.join('')
if (props.showLine) {
html = '<ol style="padding: 0px 30px;">' + html + '</ol>'
} else {
html =
'<ol style="padding: 0px 7px;list-style:none;">' +
html +
'</ol>'
}
copyCodeData.push(str)
let htmlCode = `<div class="markdown-wrap">`
htmlCode += `<div class="copy-line" style="text-align: right;font-size: 12px; margin-bottom: -10px;border-radius: 5px 5px 0 0;">`
htmlCode += `${lang}<a class="code-copy-btn" code-data-index="${
copyCodeData.length - 1
}">复制代码</a>`
htmlCode += `</div>`
htmlCode += `<pre class="hljs" style="padding:10px 8px;margin:5px 0;overflow: auto;display: block;border-radius: 5px;"><code>${html}</code></pre>`
htmlCode += '</div>'
return htmlCode
}
})
markdown.use(markdownItMath)
const parseNodes = (value) => {
if (!value) return
// <br />\n
value = value.replace(/<br>|<br\/>|<br \/>/g, '\n')
value = value.replace(/&nbsp;/g, ' ')
let htmlString = ''
if (value.split('```').length % 2) {
let mdtext = value
if (mdtext[mdtext.length - 1] != '\n') {
mdtext += '\n'
}
htmlString = markdown.render(mdtext)
} else {
htmlString = markdown.render(value)
}
//
htmlString = htmlString.replace(/<table/g, `<table class="table"`)
htmlString = htmlString.replace(/<tr/g, `<tr class="tr"`)
htmlString = htmlString.replace(/<th>/g, `<th class="th">`)
htmlString = htmlString.replace(/<td/g, `<td class="td"`)
htmlString = htmlString.replace(/<hr>|<hr\/>|<hr \/>/g, `<hr class="hr">`)
// #ifndef APP-NVUE
return htmlString
// #endif
// htmlStringhtmlArray使rich-text
// #ifdef APP-NVUE
return parseHtml(htmlString)
// #endif
}
//
const handleItemClick = (e) => {
const { 'code-data-index': codeDataIndex, class: className } = e
if (className == 'code-copy-btn') {
// #ifdef H5
uni.setClipboardData({
data: copyCodeData[codeDataIndex],
showToast: false,
success() {
uni.showToast({
title: '复制成功',
icon: 'none'
})
}
})
// #endif
// #ifndef H5
const { copy } = useCopy()
console.log(copyCodeData[codeDataIndex])
copy(copyCodeData[codeDataIndex])
// #endif
}
}
</script>

View File

@ -0,0 +1,48 @@
<template>
<view
class="banner h-[340rpx] bg-white translate-y-0"
v-if="content.data.length && content.enabled"
>
<swiper
class="swiper h-full"
:indicator-dots="content.data.length > 1"
indicator-active-color="#4173ff"
:autoplay="true"
>
<swiper-item
v-for="(item, index) in content.data"
:key="index"
@click="handleClick(item.link)"
>
<u-image
mode="aspectFit"
width="100%"
height="100%"
:src="getImageUrl(item.image)"
/>
</swiper-item>
</swiper>
</view>
</template>
<script setup lang="ts">
import { useAppStore } from '@/stores/app'
import { navigateTo } from '@/utils/util'
const props = defineProps({
content: {
type: Object,
default: () => ({})
},
styles: {
type: Object,
default: () => ({})
}
})
const { getImageUrl } = useAppStore()
const handleClick = (link: any) => {
navigateTo(link)
}
</script>
<style></style>

View File

@ -0,0 +1,89 @@
<template>
<view
class="customer-service bg-white flex flex-col justify-center items-center mx-[36rpx] mt-[20rpx] rounded-lg px-[110rpx] pt-[100rpx] pb-[160rpx]"
>
<u-image width="280" height="280" :src="getImageUrl(content.qrcode)" />
<view
v-if="content.title && +content.title_status"
class="text-lg mt-[14rpx] font-medium"
>
{{ content.title }}
</view>
<view
v-if="content.time && +content.time_status"
class="text-content mt-[40rpx]"
>
服务时间{{ content.time }}
</view>
<view
v-if="content.mobile && +content.mobile_status"
class="text-content mt-[14rpx] flex flex-wrap"
>
客服电话{{ content.mobile }}
<!-- #ifdef H5 -->
<a
class="ml-[10rpx] phone text-muted underline"
:href="'tel:' + content.mobile"
>
拨打
</a>
<!-- #endif -->
<!-- #ifndef H5 -->
<view
class="ml-[10rpx] phone text-muted underline"
@click="handleCall"
>
拨打
</view>
<!-- #endif -->
</view>
<view class="mt-[100rpx] w-full">
<u-button
type="primary"
shape="circle"
@click="onSaveQrcode(getImageUrl(content.qrcode))"
>
保存二维码图片
</u-button>
</view>
</view>
</template>
<script lang="ts" setup>
import { useAppStore } from '@/stores/app'
import { saveImageToPhotosAlbum } from '@/utils/file'
import { client } from '@/utils/client'
import { ClientEnum } from '@/enums/appEnums'
import { usePermissionsStore } from '@/stores/androidPermissions'
const props = defineProps({
content: {
type: Object,
default: () => ({})
},
styles: {
type: Object,
default: () => ({})
}
})
const { getImageUrl } = useAppStore()
const handleCall = () => {
uni.makePhoneCall({
phoneNumber: String(props.content.mobile)
})
}
const onSaveQrcode = async (qrcode: string) => {
// #ifdef APP-PLUS
if (client == ClientEnum['ANDROID']) {
const { requestPermissions } = usePermissionsStore()
const result = await requestPermissions('WRITE_EXTERNAL_STORAGE')
if (result !== 1) return
}
// #endif
saveImageToPhotosAlbum(qrcode)
}
</script>
<style lang="scss"></style>

View File

@ -0,0 +1,79 @@
<template>
<div class="my-service bg-white mx-[20rpx] mb-[20rpx] rounded-lg">
<div
v-if="content.style == 1"
class="flex flex-wrap pt-[40rpx] pb-[20rpx]"
>
<div
v-for="(item, index) in showList"
:key="index"
class="flex flex-col items-center w-1/4 mb-[15px]"
@click="handleClick(item.link, item.name)"
>
<u-image
width="52"
height="52"
:src="getImageUrl(item.image)"
alt=""
/>
<div class="mt-[7px]">{{ item.name }}</div>
</div>
</div>
<div v-if="content.style == 2">
<div
v-for="(item, index) in showList"
:key="index"
class="flex items-center nav-item h-[100rpx] px-[24rpx]"
@click="handleClick(item.link, item.name)"
>
<u-image
width="48"
height="48"
:src="getImageUrl(item.image)"
alt=""
/>
<div class="ml-[20rpx] flex-1">{{ item.name }}</div>
<div class="text-muted">
<u-icon name="arrow-right" />
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { useAppStore } from '@/stores/app'
import { navigateTo } from '@/utils/util'
import { computed } from 'vue'
const props = defineProps({
content: {
type: Object,
default: () => ({})
},
styles: {
type: Object,
default: () => ({})
}
})
const { getImageUrl } = useAppStore()
const showList = computed(() => {
return (
props.content.data?.filter((item: any) =>
item.is_show ? item.is_show == '1' : true
) || []
)
})
const handleClick = (link: any, name: string) => {
link.name = name
navigateTo(link, true)
}
</script>
<style lang="scss" scoped>
.nav-item {
&:not(:last-of-type) {
@apply border-light border-solid border-0 border-b;
}
}
</style>

View File

@ -0,0 +1,38 @@
<template>
<view class="nav pt-[30rpx] pb-[16rpx] bg-white" v-if="content.data.length && content.enabled">
<view class="nav-item flex flex-wrap">
<view
v-for="(item, index) in content.data"
:key="index"
class="flex flex-col items-center w-1/5 mb-[30rpx]"
@click="handleClick(item.link)"
>
<u-image width="41px" height="41px" :src="getImageUrl(item.image)" alt="" />
<view class="mt-[14rpx]">{{ item.name }}</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { useAppStore } from '@/stores/app'
import { navigateTo } from '@/utils/util'
const props = defineProps({
content: {
type: Object,
default: () => ({})
},
styles: {
type: Object,
default: () => ({})
}
})
const handleClick = (link: any) => {
navigateTo(link)
}
const { getImageUrl } = useAppStore()
</script>
<style></style>

View File

@ -0,0 +1,13 @@
<template>
<navigator
url="/pages/search/search"
class="search px-[24rpx] py-[14rpx] bg-white"
hover-class="none"
>
<u-search placeholder="请输入关键词搜索" disabled :show-action="false"></u-search>
</navigator>
</template>
<script setup lang="ts"></script>
<style></style>

View File

@ -0,0 +1,49 @@
<template>
<view
class="banner h-[200rpx] mx-[20rpx] mt-[20rpx] translate-y-0"
v-if="content.data.length && content.enabled"
>
<swiper
class="swiper h-full"
:indicator-dots="content.data.length > 1"
indicator-active-color="#4173ff"
:autoplay="true"
>
<swiper-item
v-for="(item, index) in content.data"
:key="index"
@click="handleClick(item.link)"
>
<u-image
mode="aspectFit"
width="100%"
height="100%"
:src="getImageUrl(item.image)"
:border-radius="14"
/>
</swiper-item>
</swiper>
</view>
</template>
<script setup lang="ts">
import { useAppStore } from '@/stores/app'
import { navigateTo } from '@/utils/util'
const props = defineProps({
content: {
type: Object,
default: () => ({})
},
styles: {
type: Object,
default: () => ({})
}
})
const handleClick = (link: any) => {
navigateTo(link)
}
const { getImageUrl } = useAppStore()
</script>
<style></style>

View File

@ -0,0 +1 @@
<template> <view class="user-bottom flex items-center p-[16px] mt-[20px] text-center"> <view class="text-sm w-full text-content"> <text class="">{{ content.title }}</text> <text class="mr-[4px]">{{ content.content }}</text> <image :src="iconCopy" class="w-[28rpx] h-[28rpx] align-middle" v-if="content?.canCopy" @click="copy(content.content)" ></image> </view> </view> </template> <script lang="ts" setup> import { useCopy } from '@/hooks/useCopy' import { useNavigationBarTitleStore } from '@/stores/navigationBarTitle' import iconCopy from '@/static/images/icon/icon_copy.png' const navigationBarTitleStore = useNavigationBarTitleStore() const props = defineProps({ content: { type: Object, default: () => ({}) }, styles: { type: Object, default: () => ({}) } }) const { copy } = useCopy() </script> <style lang="scss" scoped> .user-bottom { } </style>

View File

@ -0,0 +1,106 @@
<template>
<view class="user-info mb-[-70rpx]">
<!-- #ifndef H5 -->
<u-sticky
h5-nav-height="0"
bg-color="transparent"
@fixed="isFixed = true"
@unfixed="isFixed = false"
>
<u-navbar
:is-back="false"
:is-fixed="false"
:title="navigationBarTitleStore.getTitle"
:title-color="$theme.navColor"
:background="{ backgroundColor: getNavBg }"
:border-bottom="false"
:title-bold="true"
>
</u-navbar>
</u-sticky>
<!-- #endif -->
<view class="flex px-[50rpx] pb-[100rpx] justify-between pt-[40rpx]">
<view
v-if="isLogin"
class="flex items-center"
@click="navigateTo('/packages/pages/user_set/user_set')"
>
<u-avatar :src="user.avatar" :size="120"></u-avatar>
<view class="text-btn-text ml-[20rpx]">
<view class="flex items-center">
<view class="text-2xl font-medium">
{{ user.nickname }}
</view>
<view
class="flex-none ml-[16rpx] text-xs text-white rounded-[6rpx] px-[10rpx] py-[6rpx]"
:class="{
'text-[#F8C596]': user.isMember
}"
:style="{
background: user.isMember
? 'linear-gradient(90.00deg, #484848 0%, #101010 100%)'
: '#4073fa'
}"
>
{{ user.isMember ? user.memberPackageName : '普通用户' }}
</view>
</view>
<view class="text-xs mt-[18rpx]" @click.stop="copy(user.sn)">
用户ID{{ user.sn }}
<text class="underline">复制</text>
</view>
</view>
</view>
<navigator v-else class="flex items-center" hover-class="none" url="/pages/login/login">
<u-avatar src="/static/images/user/default_avatar.png" :size="120"></u-avatar>
<view class="text-btn-text text-3xl ml-[20rpx]">未登录</view>
</navigator>
</view>
</view>
</template>
<script lang="ts" setup>
import { useCopy } from '@/hooks/useCopy'
import { computed, ref } from 'vue'
import { useThemeStore } from '@/stores/theme'
import { useRouter } from 'uniapp-router-next'
import { useNavigationBarTitleStore } from '@/stores/navigationBarTitle'
const navigationBarTitleStore = useNavigationBarTitleStore()
const props = defineProps({
content: {
type: Object,
default: () => ({})
},
styles: {
type: Object,
default: () => ({})
},
user: {
type: Object,
default: () => ({})
},
isLogin: {
type: Boolean
}
})
const router = useRouter()
const { copy } = useCopy()
const isFixed = ref(false)
const themeStore = useThemeStore()
const getNavBg = computed(() => {
return isFixed.value ? themeStore.primaryColor : 'transparent'
})
const navigateTo = (url: string) => {
router.navigateTo(url)
}
</script>
<style lang="scss" scoped>
.user-info {
background: url('../../../static/images/user/user_bg.png'),
linear-gradient(90deg, $u-type-primary, $u-minor-color);
background-repeat: no-repeat;
background-position: bottom;
background-size: 100%;
}
</style>

View File

@ -0,0 +1,96 @@
<template>
<view class="user-vip mx-[20rpx] rounded-lg mb-[20rpx]">
<view class="p-[30rpx] flex" v-if="appStore.getIsShowVip">
<u-image width="80" height="80" :src="getImageUrl(content.icon)" alt="" />
<view class="flex justify-between w-full">
<view class="ml-[20rpx] flex flex-col justify-between">
<view class="text-[32rpx] text-[#55300F] font-bold">
{{ user.isMember ? '您已成为VIP会员' : content.title }}
</view>
<view class="text-[24rpx] text-[#6A3D15]">
{{
user.isMember
? user.memberPerpetual
? '有效期至:永久'
: `有效期至:${user.memberExpire}`
: content.sub_title
}}
</view>
</view>
<view class="flex flex-col justify-center" v-if="isLogin">
<u-button
v-if="appStore.getIsShowVip && !user.memberPerpetual"
shape="circle"
size="medium"
:customStyle="{
padding: '0 24rpx',
height: '56rpx',
background: '#333333',
color: '#F8C596'
}"
hover-class="none"
@click="navigateTo('/packages/pages/open_vip/open_vip')"
>
{{ user.isMember ? '立即续费' : content.btn }}
</u-button>
</view>
</view>
</view>
<router-navigate to="/packages/pages/recharge/recharge">
<view class="bg-white rounded-lg flex p-[30rpx] pr-[24rpx]">
<view class="flex-1">
<view>对话余额</view>
<view class="flex items-center mt-[20rpx]">
<text class="text-primary"> {{ user.balance || 0 }} </text>
</view>
</view>
<view class="flex-1" v-if="appStore.getDrawConfig.is_open">
<view>绘画余额</view>
<view class="flex items-center mt-[20rpx]">
<text class="text-primary"> {{ user.balanceDraw || 0 }} </text>
</view>
</view>
<view class="text-muted flex">
<u-icon name="arrow-right" />
</view>
</view>
</router-navigate>
</view>
</template>
<script setup lang="ts">
import { useAppStore } from '@/stores/app'
import { useRouter } from 'uniapp-router-next'
const props = defineProps({
content: {
type: Object,
default: () => ({})
},
styles: {
type: Object,
default: () => ({})
},
user: {
type: Object,
default: () => ({})
},
isLogin: {
type: Boolean
}
})
const router = useRouter()
const appStore = useAppStore()
const { getImageUrl } = useAppStore()
const navigateTo = (path: string) => {
// uni.navigateTo({ url: path })
router.navigateTo(path)
// console.log(path)
// uni.switchTab({ url: path })
}
</script>
<style lang="scss">
.user-vip {
background: linear-gradient(90deg, #ffe8cf 0%, #ffe8cf 20%, #e1ab7a 100%);
}
</style>

23
src/config/index.ts Normal file
View File

@ -0,0 +1,23 @@
import { isDevMode } from "@/utils/env";
const envBaseUrl = import.meta.env.VITE_APP_BASE_URL || "";
let baseUrl = `${envBaseUrl}/`;
/*
* `VITE_APP_BASE_URL``dev`
* 使`VITE_APP_BASE_URL`
* 使`[baseUrl]`便
*/
//#ifdef MP-WEIXIN
baseUrl = isDevMode() || envBaseUrl ? baseUrl : "[baseUrl]";
//#endif
const config = {
version: "2.4.0", //版本号
baseUrl, //请求接口域名
urlPrefix: "api", //请求默认前缀
timeout: 30 * 1000, //请求超时时长
};
export default config;

View File

@ -0,0 +1,5 @@
//菜单主题类型
export enum AgreementEnum {
PRIVACY = 'privacy',
SERVICE = 'service'
}

50
src/enums/appEnums.ts Normal file
View File

@ -0,0 +1,50 @@
//菜单主题类型
export enum ThemeEnum {
LIGHT = 'light',
DARK = 'dark'
}
// 客户端
export enum ClientEnum {
MP_WEIXIN = 1, // 微信-小程序
OA_WEIXIN = 2, // 微信-公众号
H5 = 3, // H5
IOS = 5, //苹果
ANDROID = 6 //安卓
}
export enum SMSEnum {
LOGIN = 101,
BIND_MOBILE = 102,
CHANGE_MOBILE = 103,
FIND_PASSWORD = 104,
REGISTER = 105
}
export enum SearchTypeEnum {
HISTORY = 'history'
}
// 用户资料
export enum FieldType {
NONE = '',
AVATAR = 'avatar',
USERNAME = 'username',
NICKNAME = 'nickname',
SEX = 'sex'
}
// 支付结果
export enum PayStatusEnum {
SUCCESS = 'success',
FAIL = 'fail',
PENDING = 'pending'
}
// 页面状态
export enum PageStatusEnum {
LOADING = 'loading', // 加载中
NORMAL = 'normal', // 正常
ERROR = 'error', // 异常
EMPTY = 'empty' // 为空
}

9
src/enums/cacheEnums.ts Normal file
View File

@ -0,0 +1,9 @@
// 本地缓冲key
//token
export const TOKEN_KEY = 'token'
// 搜索历史记录
export const HISTORY = 'history'
export const BACK_URL = 'back_url'

View File

@ -0,0 +1,27 @@
// 本地缓冲key
//token
export const TOKEN_KEY = 'token'
export const IS_CLOSE_FOLLOW_OA = 'is_close_follow_oa'
// 搜索历史记录
export const HISTORY = 'history'
export const BACK_URL = 'back_url'
export const SHARE_ID = 'share_id'
export const USER_SN = 'user_sn'
export const PAY_STATUS_EVENT = 'event:payStatus'
// 公告缓存key
export const NOTICE = 'notice'
export const HAS_READ_PRIVACY = 'has_read_privacy'
export const CHAT_LIMIT_KEY = 'chan_limit'
export const DRAW_LIMIT_KEY = 'draw_limit'
export const QRCODE_LIMIT_KEY = 'qrcode_limit'

34
src/enums/requestEnums.ts Normal file
View File

@ -0,0 +1,34 @@
export enum ContentTypeEnum {
// json
JSON = 'application/json;charset=UTF-8',
// form-data 上传资源(图片,视频)
FORM_DATA = 'multipart/form-data;charset=UTF-8'
}
export enum RequestMethodsEnum {
GET = 'GET',
POST = 'POST'
}
export enum RequestCodeEnum {
SUCCESS = 200, //成功
FAILED = 300, // 失败
PARAMS_VALID_ERROR = 310, //参数校验错误
PARAMS_TYPE_ERROR = 311, //参数类型错误
REQUEST_METHOD_ERROR = 312, //请求方法错误
ASSERT_ARGUMENT_ERROR = 313, //断言参数错误
ASSERT_MYBATIS_ERROR = 314, //断言mybatis错误
LOGIN_ACCOUNT_ERROR = 330, //登陆账号或密码错误
LOGIN_DISABLE_ERROR = 331, //登陆账号已被禁用
TOKEN_EMPTY = 332, // TOKEN参数为空
TOKEN_INVALID = 333, // TOKEN参数无效
DRAW_ERROR = 336, // 绘图失败
NO_PERMISSTION = 403, //无相关权限
REQUEST_404_ERROR = 404, //请求接口不存在
SYSTEM_ERROR = 500 //系统错误
}
export enum RequestErrMsgEnum {
ABORT = 'request:fail abort',
TIMEOUT = 'request:fail timeout'
}

87
src/hooks/useAudio.ts Normal file
View File

@ -0,0 +1,87 @@
import { onBeforeUnmount, ref, shallowRef } from 'vue'
const audioCtxs = new Set<UniApp.InnerAudioContext>()
export const useAudio = () => {
const audioCtx = shallowRef<UniApp.InnerAudioContext | null>(null)
const isPlaying = ref(false)
const duration = ref(0)
const onPlay = () => {
isPlaying.value = true
}
const onStop = () => {
isPlaying.value = false
}
const onError = (e: any) => {
console.error(e)
isPlaying.value = false
}
const onCanplay = () => {
duration.value = audioCtx.value?.duration || 0
if (duration.value == 0) {
//处理微信小程序获取不到时长的bug
setTimeout(() => {
duration.value = audioCtx.value?.duration || 0
}, 100)
}
// console.log(audioCtx.value?.buffered)
}
const createAudio = () => {
audioCtx.value = uni.createInnerAudioContext()
audioCtxs.add(audioCtx.value)
audioCtx.value.onCanplay(onCanplay)
audioCtx.value.onPlay(onPlay)
audioCtx.value.onEnded(onStop)
audioCtx.value.onError(onError)
audioCtx.value.onStop(onStop)
}
const destroy = () => {
if (audioCtx.value) {
audioCtx.value.destroy()
audioCtxs.delete(audioCtx.value)
audioCtx.value = null
}
}
const setUrl = (src: string) => {
if (!audioCtx.value) {
createAudio()
}
audioCtx.value!.src = src
}
const play = async (src?: string) => {
pauseAll()
if (!audioCtx.value) {
createAudio()
}
if (src) {
setUrl(src)
}
audioCtx.value!.play()
}
const pause = () => {
audioCtx.value?.stop()
}
const pauseAll = () => {
audioCtxs.forEach((audio) => {
if (!audio.paused) {
audio.stop()
}
})
}
onBeforeUnmount(() => {
if (isPlaying.value) {
pause()
}
destroy()
})
return {
pause,
pauseAll,
play,
duration,
isPlaying,
setUrl
}
}

189
src/hooks/useAudioPlay.ts Normal file
View File

@ -0,0 +1,189 @@
import { isAndroid, isWeixinClient } from '@/utils/client'
//#ifdef H5
import { Howl, Howler } from 'howler'
//#endif
import { onBeforeUnmount, ref, shallowRef } from 'vue'
interface Options {
api(...args: any): Promise<any>
params: Record<string, any>
dataTransform(data: any): string
onstart?(): void
onstop?(): void
}
const audioSet = new Set<UniApp.InnerAudioContext>()
export const useAudioPlay = (options?: Options) => {
const { api, dataTransform, params, onstart, onstop } = options || {}
const audio = shallowRef<UniApp.InnerAudioContext | null>(null)
const audioLoading = ref(false)
const audioPlaying = ref(false)
const _paly = async () => {
//#ifdef H5
//@ts-ignore
audio.value.load()
//@ts-ignore
audio.value.once('load', () => {
if (isWeixinClient()) {
window.WeixinJSBridge.invoke(
'getNetworkType',
{},
() => {
audio.value!.play()
},
false
)
} else {
audio.value!.play()
}
})
//#endif
//#ifndef H5
audio.value!.play()
//#endif
}
const play = async () => {
pauseAll()
if (audio.value?.src || audio.value?._src) {
await _paly()
return
}
audioLoading.value = true
try {
const data = await api?.(params)
const audioUrl = dataTransform?.(data)
if (!audioUrl) {
throw Error('获取的语音url错误')
}
if (!audio.value) {
createAudio(audioUrl)
}
audio.value!.src = audioUrl
await _paly()
} catch (error) {
console.error(error)
uni.$u.toast('语音播报异常')
} finally {
audioLoading.value = false
}
}
const pause = () => {
audio.value?.stop()
audioPlaying.value = false
}
const pauseAll = () => {
audioSet.forEach((audio) => {
audio?.stop()
//@ts-ignore
audio.audioPlaying.value = false
})
}
const onPlay = () => {
onstart?.()
audioPlaying.value = true
}
const onStop = () => {
if (audio.value?.src || audio.value?._src) {
//有src才算正真的停止
onstop?.()
}
audioPlaying.value = false
}
const onError = () => {
// onerror()
audioPlaying.value = false
}
const createAudio = (src: string) => {
// if (isWeixinClient() ) {
// #ifdef H5
//@ts-ignore
audio.value = new Howl({
src,
autoplay: false,
preload: false
})
//@ts-ignore
audio.value.audioPlaying = audioPlaying
//@ts-ignore
audio.value.on('play', onPlay)
//@ts-ignore
audio.value.on('end', onStop)
//@ts-ignore
audio.value.on('loaderror', onError)
//@ts-ignore
audio.value.on('playerror', onError)
//@ts-ignore
audio.value.on('stop', onStop)
// #endif
// } else {
// #ifndef H5
audio.value = uni.createInnerAudioContext()
//@ts-ignore
audio.value.audioPlaying = audioPlaying
audio.value.onPlay(onPlay)
audio.value.onEnded(onStop)
audio.value.onError(onError)
audio.value.onStop(onStop)
// #endif
// }
//@ts-ignore
audioSet.add(audio.value)
}
const destroy = () => {
if (!audio.value) {
return
}
// if (isWeixinClient() && audio.value) {
// #ifdef H5
//@ts-ignore
audio.value.off('play', onPlay)
//@ts-ignore
audio.value.off('end', onStop)
//@ts-ignore
audio.value.off('loaderror', onError)
//@ts-ignore
audio.value.off('playerror', onError)
//@ts-ignore
audio.value.off('stop', onStop)
// #endif
// #ifndef H5
//@ts-ignore
audio.value?.offPlay(onPlay)
//@ts-ignore
audio.value?.offEnded(onStop)
//@ts-ignore
audio.value?.offError(onError)
//@ts-ignore
audio.value?.offStop(onStop)
// #endif
audioSet.delete(audio.value!)
audio.value = null
}
onBeforeUnmount(() => {
if (audioPlaying.value) {
pause()
}
destroy()
})
return {
play,
audioLoading,
audioPlaying,
pause,
pauseAll,
destroy
}
}

View File

@ -0,0 +1,27 @@
import { ref, onMounted } from 'vue'
import { captcha } from '@/api/account'
const useCaptchaEffect = () => {
const captchaKey = ref<string>('')
const captchaImage = ref<string>('')
const getCaptchaFn = async () => {
try {
const data = await captcha()
captchaKey.value = data.uuid
captchaImage.value = data.img
} catch (error) {
console.log('获取图形码失败=>', error)
}
}
onMounted(getCaptchaFn)
return {
captchaKey,
captchaImage,
getCaptchaFn
}
}
export default useCaptchaEffect

10
src/hooks/useCopy.ts Normal file
View File

@ -0,0 +1,10 @@
export function useCopy() {
const copy = (text: string) => {
uni.setClipboardData({
data: String(text)
})
}
return {
copy
}
}

21
src/hooks/useLockFn.ts Normal file
View File

@ -0,0 +1,21 @@
import { ref } from 'vue'
export function useLockFn(fn: (...args: any[]) => Promise<any>) {
const isLock = ref(false)
const lockFn = async (...args: any[]) => {
if (isLock.value) return
isLock.value = true
try {
const res = await fn(...args)
isLock.value = false
return res
} catch (e) {
isLock.value = false
throw e
}
}
return {
isLock,
lockFn
}
}

1
src/hooks/usePolling.ts Normal file
View File

@ -0,0 +1 @@
import { ref } from 'vue' interface Options { time?: number totalTime?: number count?: number callback?(): void } // @ts-ignore export default function usePolling(fun: () => Promise<any>, options?: Options) { const result = ref(null) const error = ref(null) const { time = 2000, totalTime, count, callback = () => false } = options ?? ({} as Options) let timer: any = null let endTime: number | null = null let totalCount = 0 const run = () => { console.log('count2:', count) if (endTime && endTime <= Date.now()) { end() callback() return } if (count && totalCount >= count) { end() callback() return } totalCount++ timer = setTimeout(() => { fun() .then((res) => { result.value = res run() }) .catch((err) => { error.value = err }) }, time) } const start = () => { endTime = totalTime ? Date.now() + totalTime : null run() } const end = () => { setTimeout(() => { clearTimeout(timer) timer = null endTime = null totalCount = 0 }, 0) } return { start, end, error, result } }

672
src/hooks/useRecorder.ts Normal file
View File

@ -0,0 +1,672 @@
import {
getCurrentInstance,
onBeforeUnmount,
onMounted,
ref,
shallowRef
} from 'vue'
//#ifdef H5
import Recorder from 'recorder-core/recorder.mp3.min'
// #endif
import Mp3 from 'js-mp3'
//@ts-ignore
import genFFT from '@/lib/fft.js'
interface H5RecordOptions {
type: 'mp3' | 'wav'
bitRate: number
sampleRate: number
}
type Options = H5RecordOptions & UniApp.RecorderManagerStartOptions
interface RecorderResult {
tempFilePath: string
duration: number
}
interface DataResult {
pcmData: Int16Array
powerLevel: number
sampleRate: number
}
interface callbacks {
onstart?(): void
onstop?(result: RecorderResult): void
ondata?(result: DataResult): void
}
export const useRecorder = (callbacks: callbacks, options?: Options) => {
options = options || {
type: 'mp3',
sampleRate: 32000,
bitRate: 32,
duration: 600000,
numberOfChannels: 1, //录音通道数
encodeBitRate: 64000,
format: 'mp3', //音频格式,有效值 aac/mp3 等
frameSize: 1 //指定帧大小,单位 KB
}
const isRecording = ref(false)
const mediaRecorder = shallowRef()
// const isAuth = ref(false)
const createMediaRecorder = () => {
//#ifdef H5
mediaRecorder.value = Recorder({
...options,
async onProcess(
pcmdata: Int16Array[],
powerLevel: number,
duration: number,
sampleRate: number
) {
callbacks?.ondata?.({
pcmData: pcmdata[pcmdata.length - 1],
powerLevel,
sampleRate
})
}
})
//#endif
//#ifndef H5
mediaRecorder.value = uni.getRecorderManager()
//#endif
}
/**
* @description
* @returns
*/
const authorize = (): Promise<void> => {
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve, reject) => {
//优化点:已经获取过权限,不必再获取
// if (isAuth.value) {
// resolve()
// return
// }
if (!mediaRecorder.value) {
createMediaRecorder()
}
//#ifdef H5
mediaRecorder.value.open(
() => {
// isAuth.value = true
resolve()
},
(msg: string) => {
reject('无法录音:' + msg)
}
)
//#endif
//#ifdef MP
try {
await uni.authorize({
scope: 'scope.record'
})
resolve()
} catch (error: any) {
const res = await uni.showModal({
title: '开启麦克风权限',
content: '为了正常使用语音输入功能,请开启麦克风权限',
confirmText: '去设置'
})
if (res.confirm) {
const { authSetting } = await uni.openSetting()
if (authSetting['scope.record']) {
// isAuth.value = true
return resolve()
}
}
reject('无法录音:用户拒绝了麦克风权限')
}
//#endif
})
}
/**
*
* @param options
* @returns
* @description
*/
const start = async () => {
// 注册事件
try {
if (!mediaRecorder.value) {
createMediaRecorder()
}
mediaRecorder.value.start(options)
isRecording.value = true
callbacks.onstart?.()
//#ifndef H5
mediaRecorder.value.onStart(() => {
isRecording.value = true
callbacks.onstart?.()
})
mediaRecorder.value.onStop((e: any) => {
isRecording.value = false
callbacks.onstop?.(e)
})
mediaRecorder.value.onError((e: any) => {
isRecording.value = false
})
// 被打断结束,恢复录音
mediaRecorder.value.onInterruptionEnd(() => {
mediaRecorder.value.resume()
})
mediaRecorder.value.onFrameRecorded(
async ({ frameBuffer }: any) => {
const decoder = Mp3.newDecoder(frameBuffer)
if (decoder != null) {
const pcmArrayBuffer = decoder.decode()
const pcmArr = new Int16Array(pcmArrayBuffer)
const size = pcmArr.length
let sum = 0
for (let i = 0; i < size; i++) {
sum += Math.abs(pcmArr[i])
}
let powerLevel = (sum * 500.0) / (size * 16383)
if (powerLevel >= 100) {
powerLevel = 100
}
if (powerLevel <= 5) {
powerLevel = 1
}
powerLevel = parseInt(String(powerLevel))
callbacks?.ondata?.({
pcmData: pcmArr,
powerLevel,
sampleRate: options?.sampleRate!
})
}
}
)
//#endif
} catch (error) {
console.log(error)
return Promise.reject(error)
}
}
const stop = () => {
if (mediaRecorder.value) {
//#ifndef H5
mediaRecorder.value?.stop()
//#endif
//#ifdef H5
mediaRecorder.value?.stop(
(blob: Blob, duration: number) => {
const tempFilePath = window.URL.createObjectURL(blob)
isRecording.value = false
return callbacks.onstop?.({
tempFilePath,
duration
})
},
() => {
isRecording.value = false
}
)
//#endif
}
//#ifndef H5
mediaRecorder.value?.offStart?.()
mediaRecorder.value?.offStop?.()
mediaRecorder.value?.offError?.()
mediaRecorder.value?.offInterruptionEnd?.()
mediaRecorder.value?.onFrameRecorded?.()
//#endif
}
const close = () => {
mediaRecorder.value?.close?.(() => {
isRecording.value = false
})
mediaRecorder.value = null
// isAuth.value = false
}
onBeforeUnmount(() => {
stop()
close()
})
return {
isRecording,
mediaRecorder,
start,
authorize,
stop,
close
}
}
interface AudioGraphOptions {
/**
* @description canvasid
*/
id: string
/**
* @description
*/
width: number
/**
* @description
*/
height: number
/**
* @description 使2(3? no!)
*/
scale: number
/**
* @description
*/
fps: number
/**
* @description
*/
fftSize: number
/**
* @description
*/
lineCount: number
/**
* @description
*/
minHeight: number
/**
* @description 线
* 0.60.4
* 1
*/
widthRatio: number
/**
* @description 0widthRatio无效
*/
spaceWidth: number
/**
* @description position: -1, //绘制位置,取值-1到1-1为最底下0为中间1为最顶上支持小数点
*/
position: number
/**
* @description
*/
mirrorEnable: boolean
/**
* @description ms
*/
fallDuration: number
/**
* @description
* @example [{pos:0,color:"#fff"},{pos:1,color"#000"}]
*/
linear: { pos: number; color: string }[]
/**
* @description
*/
round: boolean
/**
* @description false主要绘制5khz以下的频率true后不同采样率下显示的频谱是不一样的 =/2
//当发生绘制时会回调此方法参数为当前绘制的频率数据和采样率可实现多个直方图同时绘制只消耗一个input输入和计算时间
*/
fullFreq: boolean
onDraw?(
ctx: UniApp.CanvasContext,
data: {
frequencyData: Float64Array | null
sampleRate: number
options: AudioGraphOptions
}
): void
}
export type AudioGraphUserOptions = Partial<AudioGraphOptions> &
Pick<AudioGraphOptions, 'id' | 'width' | 'height'>
const defaultAudioGraphOptions: AudioGraphOptions = {
id: '',
width: 0,
height: 0,
scale: 2,
fps: 30,
fftSize: 1024,
lineCount: 6,
widthRatio: 0.6,
spaceWidth: 0,
minHeight: 8,
position: 0,
mirrorEnable: false,
fallDuration: 600,
linear: [
{
pos: 0,
color: 'white'
},
{
pos: 1,
color: 'white'
}
],
round: true,
fullFreq: false
}
export const useRenderAudioGraph = (options: AudioGraphUserOptions) => {
const canvasId = options.id
if (!canvasId) {
console.error('绘制图形前必须指定`canvasId`')
}
const instance = getCurrentInstance()
const opt: AudioGraphOptions = Object.assign(
{},
defaultAudioGraphOptions,
options
)
const canvasCtx = uni.createCanvasContext(canvasId, instance?.proxy)
if (!opt.width || !opt.height) {
console.error('必须指定画布的宽高')
}
const fft = genFFT(opt.fftSize)
let fragment: DataResult | undefined = undefined
let pcmPos = 0
let inputTime = 0
let scheduleTimer = 0
let drawTime = 0
let lastH: number[] = []
const render = (data: DataResult) => {
fragment = data
pcmPos = 0
inputTime = Date.now()
schedule()
}
const genLinear = (
ctx: UniApp.CanvasContext,
colors: AudioGraphOptions['linear'],
from: number,
to: number
) => {
const rtv = ctx.createLinearGradient(0, from, 0, to)
for (let i = 0; i < colors.length; i++) {
rtv.addColorStop(colors[i].pos, colors[i].color)
}
return rtv
}
const drawRect = (
ctx: UniApp.CanvasContext,
x: number,
y: number,
width: number,
height: number,
r: number[]
) => {
const [r1, r2, r3, r4] = r
ctx.beginPath()
ctx.moveTo(x + r1, y)
ctx.lineTo(x + width - r1, y)
ctx.arc(x + width - r2, y + r2, r2, Math.PI * 1.5, Math.PI * 2)
ctx.lineTo(x + width, y + height - r3)
ctx.arc(x + width - r3, y + height - r3, r3, 0, Math.PI * 0.5)
ctx.lineTo(x + r4, y + height)
ctx.arc(x + r4, y + height - r4, r4, Math.PI * 0.5, Math.PI)
ctx.lineTo(x, y + r1)
ctx.arc(x + r1, y + r1, r1, Math.PI, Math.PI * 1.5)
ctx.fill()
}
const onDraw: AudioGraphOptions['onDraw'] = opt.onDraw
? opt.onDraw
: (ctx, { frequencyData, sampleRate, options }) => {
const {
scale,
width,
height,
lineCount,
round,
fftSize,
position,
fallDuration,
fps,
fullFreq,
linear,
mirrorEnable
} = options
const realWidth = width * scale
const realHeight = height * scale
//计算高度位置
const posAbs = Math.abs(position)
let originY = position == 1 ? 0 : realHeight //y轴原点
let heightY = realHeight //最高的一边高度
if (posAbs < 1) {
heightY = heightY / 2
originY = heightY
heightY = Math.floor(heightY * (1 + posAbs))
originY = Math.floor(
position > 0
? originY * (1 - posAbs)
: originY * (1 + posAbs)
)
}
const lastHeight = lastH
// 计算速度
const speed = Math.ceil(heightY / (fallDuration / (1000 / fps)))
const Y0 =
1 << (Math.round(Math.log(fftSize) / Math.log(2) + 3) << 1)
const logY0 = Math.log(Y0) / Math.log(10)
const dBmax = (20 * Math.log(0x7fff)) / Math.log(10)
const fftSizeHalf = fftSize / 2.5
let fftSize5k = fftSizeHalf
//非绘制所有频率时计算5khz所在位置8000采样率及以下最高只有4khz
if (!fullFreq) {
fftSize5k = Math.min(
fftSizeHalf,
Math.floor((fftSizeHalf * 5000) / (sampleRate / 2))
)
}
const isFullFreq = fftSize5k == fftSize
const line80 = isFullFreq
? lineCount
: Math.round(lineCount * 0.8) //80%的柱子位置
const fftSizeStep1 = fftSize5k / line80
const fftSizeStep2 = isFullFreq
? 0
: (fftSizeHalf - fftSize5k) / (lineCount - line80)
let fftIdx = 0
for (let i = 0; i < lineCount; i++) {
// !fullFreq 时不采用jmp123的非线性划分频段录音语音并不适用于音乐的频率应当弱化高频部分
//80%关注0-5khz主要人声部分 20%关注剩下的高频,这样不管什么采样率都能做到大部分频率显示一致。
const start = Math.ceil(fftIdx)
if (i < line80) {
//5khz以下
fftIdx += fftSizeStep1
} else {
//5khz以上
fftIdx += fftSizeStep2
}
let end = Math.ceil(fftIdx)
if (end == start) end++
end = Math.min(end, fftSizeHalf)
//参考AudioGUI.java .drawHistogram方法
//查找当前频段的最大"幅值"
let maxAmp = 0
if (frequencyData) {
for (let j = start; j < end; j++) {
maxAmp = Math.max(maxAmp, Math.abs(frequencyData[j]))
}
}
//计算音量
const dB =
maxAmp > Y0
? Math.floor(
(Math.log(maxAmp) / Math.log(10) - logY0) * 17
)
: 0
let h = heightY * Math.min(dB / dBmax, 1)
//使柱子匀速下降
lastHeight[i] = (lastHeight[i] || 0) - speed
if (h < lastHeight[i]) {
h = lastHeight[i]
}
if (h < 0) {
h = 0
}
lastHeight[i] = h
}
//开始绘制图形
ctx.clearRect(0, 0, realWidth, realHeight)
const d = 1 / opt.scale
ctx.scale(d, d)
const linear1 = genLinear(ctx, linear, originY, originY - heightY) //上半部分的填充
const linear2 = genLinear(ctx, linear, originY, originY + heightY) //下半部分的填充
const mirrorCount = mirrorEnable ? lineCount * 2 - 1 : lineCount //镜像柱子数量翻一倍-1根
const spaceWidth = options.spaceWidth * scale
let widthRatio = options.widthRatio
if (spaceWidth != 0) {
widthRatio =
(realWidth - spaceWidth * (mirrorCount + 1)) / realWidth
}
let lineWN = 0,
spaceFloat = 0,
lineWF = 0
for (let i = 0; i < 2; i++) {
const lineFloat = Math.max(
1 * scale,
(realWidth * widthRatio) / mirrorCount
) //柱子宽度至少1个单位
lineWN = Math.floor(lineFloat)
lineWF = lineFloat - lineWN //提取出小数部分
spaceFloat =
(realWidth - mirrorCount * lineFloat) / (mirrorCount + 1) //均匀间隔,首尾都留空,可能为负数,柱子将发生重叠
if (spaceFloat > 0 && spaceFloat < 1) {
widthRatio = 1
spaceFloat = 0 //不够一个像素,丢弃不绘制间隔,重新计算
} else break
}
//绘制
const minHeight = options.minHeight * scale
const XFloat = mirrorEnable
? (realWidth - lineWN) / 2 - spaceFloat
: 0 //镜像时,中间柱子位于正中心
for (let iMirror = 0; iMirror < 2; iMirror++) {
if (iMirror) {
ctx.save()
ctx.scale(-1, 1)
}
const xMirror = iMirror ? realWidth : 0 //绘制镜像部分不用drawImage(canvas)进行镜像绘制提升兼容性iOS微信小程序bug https://developers.weixin.qq.com/community/develop/doc/000aaca2148dc8a235a0fb8c66b000
//绘制柱子
for (
let i = 0, xFloat = XFloat, wFloat = 0, x, y, w, h;
i < lineCount;
i++
) {
xFloat += spaceFloat
x = Math.floor(xFloat) - xMirror
w = lineWN
wFloat += lineWF
if (wFloat >= 1) {
w++
wFloat--
} //小数凑够1像素
h = Math.max(lastH[i], minHeight)
// console.log(h)
const radius = round ? w / 2 : 0
//绘制上半部分
let r = new Array(4).fill(radius)
if (originY != 0) {
y = originY - h
ctx.setFillStyle(linear1)
if (originY != realHeight) {
r = [radius, radius, 0, 0]
}
drawRect(ctx, x, y, w, h, r)
}
//绘制下半部分
if (originY != realHeight) {
ctx.setFillStyle(linear2)
if (originY != 0) {
r = [0, 0, radius, radius]
}
drawRect(ctx, x, originY, w, h, r)
}
xFloat += w
}
if (iMirror) {
ctx.restore()
}
if (!mirrorEnable) break
}
ctx.draw()
}
delete opt.onDraw
const draw = (frequencyData: Float64Array | null, sampleRate: number) => {
onDraw(canvasCtx, {
frequencyData,
sampleRate: sampleRate,
options: opt
})
}
const schedule = () => {
const interval = Math.floor(1000 / opt.fps)
if (!scheduleTimer) {
scheduleTimer = setInterval(function () {
schedule()
}, interval)
}
const now = Date.now()
drawTime = drawTime || 0
if (now - inputTime > opt?.fallDuration * 1.5) {
//超时没有输入,顶部横条已全部落下,干掉定时器
clearInterval(scheduleTimer)
lastH = [] //重置高度再绘制一次,避免定时不准没到底就停了
draw(null, fragment!.sampleRate)
return
}
if (now - drawTime < interval) {
//没到间隔时间,不绘制
return
}
drawTime = now
//调用FFT计算频率数据
const bufferSize = fft.bufferSize
const pcm = fragment!.pcmData
let pos = pcmPos
const arr = new Int16Array(bufferSize)
for (let i = 0; i < bufferSize && pos < pcm.length; i++, pos++) {
arr[i] = pcm[pos]
}
pcmPos = pos
const frequencyData = fft.transform(arr)
draw(frequencyData, fragment!.sampleRate)
}
const stopRender = () => {
clearInterval(scheduleTimer)
}
return {
render,
draw,
stopRender
}
}

View File

@ -0,0 +1,176 @@
import router from '@/router'
import appConfig from '@/config'
import { getShareId, shareClick } from '@/api/task'
import { paramsToStr } from '@/utils/util'
import { useNavigationBarTitleStore } from '@/stores/navigationBarTitle'
import { useAppStore } from '@/stores/app'
import shareMixin from '@/mixins/share'
export interface ShareOptions {
desc: string
title: string
imageUrl: string
path: string
}
export type UserShareOptions = Partial<ShareOptions>
//生成分享路径,首页和当前页面两种
export async function generateSharePath(isHome = false) {
const route = router.currentRoute.value
let origin = ''
//#ifdef H5
origin = `${window.location.origin}/mobile`
//#endif
//#ifdef APP-PLUS
origin = `${appConfig.baseUrl}mobile`
//#endif
const config = {
path: isHome ? '/pages/index/index' : route.path,
query: isHome ? {} : route.query
}
const path = `${origin}${config.path}`
const options: any = config.query
try {
const { shareId } = await getShareId()
if (shareId) {
options.share_id = shareId
}
} catch (error) {}
return `${path}${paramsToStr(options)}`
}
export function useShareMessage() {
const resolvedH5Options = (options: ShareOptions) => {
return {
desc: options.desc,
image_url: options.imageUrl,
link: options.path,
title: options.title
}
}
const resolvedMpOptions = (options: ShareOptions) => {
return {
imageUrl: options.imageUrl,
path: options.path,
title: options.title
}
}
/**
* @description
* @param options
* @returns
*/
const resolveOptions = async (
options: UserShareOptions = {}
): Promise<ShareOptions> => {
const navigationBarTitleStore = useNavigationBarTitleStore()
const route = router.currentRoute.value
const appStore = useAppStore()
const { style } =
(router.routeMatcher.getRouteByPath(route.path) as any) || {}
uni.showLoading({
title: '请稍后...'
})
const { shareTitle, shareImage, shareContent, sharePage } =
appStore.getShareConfig
const { name, logo } = appStore.getWebsiteConfig
// 分享为首页
const isShareWithHome = sharePage == 2
const link = await generateSharePath(isShareWithHome)
let resolved = {
title: shareTitle,
path: link,
desc: shareContent,
image_url: shareImage
}
// 非首页可以合并外部参数
if (!isShareWithHome) {
resolved = {
...resolved,
...options
}
}
if (!resolved.title) {
if (isShareWithHome) {
resolved.title = name
} else {
// 1. 用户点击进入页面的后台配置标题
// 2. 页面内pagesjson组册的页面标题
// 3. 网站名称
resolved.title =
navigationBarTitleStore.getTitle ||
style?.navigationBarTitleText ||
name
}
}
if (!resolved.imageUrl) {
resolved.imageUrl = logo
}
console.log(resolved)
// #ifdef H5
resolved = resolvedH5Options(resolved) as any
// #endif
// #ifdef MP-WEIXIN
resolved = resolvedMpOptions(resolved) as any
// #endif
uni.hideLoading()
return resolved as ShareOptions
}
// 使用分享,可以单独在页面中使用
const useShare = (options: UserShareOptions = {}) => {
// #ifdef H5
const registerEvent = () => {
//@ts-ignore
WeixinJSBridge.on('menu:share:appmessage', async function () {
const resolved = await resolveOptions(options)
//@ts-ignore
WeixinJSBridge.invoke('sendAppMessage', resolved)
})
//@ts-ignore
WeixinJSBridge.on('menu:share:timeline', async function () {
const resolved = await resolveOptions(options)
//@ts-ignore
WeixinJSBridge.invoke('shareTimeline', resolved)
})
}
//@ts-ignore
if (typeof WeixinJSBridge === 'object') {
registerEvent()
} else {
document.addEventListener(
'WeixinJSBridgeReady',
registerEvent,
false
)
}
// #endif
}
const removeMixinShare = () => {
;(shareMixin as any).onShareAppMessage = undefined
}
return {
resolveOptions,
useShare,
removeMixinShare
}
}
export async function useSharedId() {
const options = uni.getEnterOptionsSync()
const share_id = options.query.share_id
if (share_id) {
await shareClick({
share_id
})
}
}

72
src/hooks/useTouch.ts Normal file
View File

@ -0,0 +1,72 @@
import { reactive } from 'vue'
/**
* @description
* @return { Function }
*/
export function useTouch() {
// 最小移动距离
const MIN_DISTANCE = 10
const touch = reactive({
direction: '',
deltaX: 0,
deltaY: 0,
offsetX: 0,
offsetY: 0
})
/**
* @description
* @return { string }
*/
const getDirection = (x: number, y: number) => {
if (x > y && x > MIN_DISTANCE) {
return 'horizontal'
}
if (y > x && y > MIN_DISTANCE) {
return 'vertical'
}
return ''
}
/**
* @description
*/
const resetTouchStatus = () => {
touch.direction = ''
touch.deltaX = 0
touch.deltaY = 0
touch.offsetX = 0
touch.offsetY = 0
}
/**
* @description
*/
const touchStart = (event: any) => {
resetTouchStatus()
const events = event.touches[0]
touch.startX = events.clientX
touch.startY = events.clientY
}
/**
* @description
*/
const touchMove = (event: any) => {
const events = event.touches[0]
touch.deltaX = events.clientX - touch.startX
touch.deltaY = events.clientY - touch.startY
touch.offsetX = Math.abs(touch.deltaX)
touch.offsetY = Math.abs(touch.deltaY)
touch.direction = touch.direction || getDirection(touch.offsetX, touch.offsetY)
}
return {
touch,
resetTouchStatus,
touchStart,
touchMove
}
}

114
src/lib/fft.js Normal file
View File

@ -0,0 +1,114 @@
/*
时域转频域快速傅里叶变换(FFT)
var fft = genFFT(bufferSize)
bufferSize取值2的n次方
fft.bufferSize 实际采用的bufferSize
fft.transform(inBuffer)
inBuffer:[Int16,...] 数组长度必须是bufferSize
返回[Float64(Long),...]长度为bufferSize/2
*/
/*
从FFT.java 移植Java开源库jmp123 版本0.3
https://www.iteye.com/topic/851459
*/
const genFFT = function (bufferSize) {
var FFT_N_LOG, FFT_N, MINY;
var real, imag, sintable, costable;
var bitReverse;
var FFT_Fn = function (bufferSize) {
//bufferSize只能取值2的n次方
FFT_N_LOG = Math.round(Math.log(bufferSize) / Math.log(2));
FFT_N = 1 << FFT_N_LOG;
MINY = (FFT_N << 2) * Math.sqrt(2);
real = [];
imag = [];
sintable = [0];
costable = [0];
bitReverse = [];
var i, j, k, reve;
for (i = 0; i < FFT_N; i++) {
k = i;
for (j = 0, reve = 0; j != FFT_N_LOG; j++) {
reve <<= 1;
reve |= k & 1;
k >>>= 1;
}
bitReverse[i] = reve;
}
var theta,
dt = (2 * Math.PI) / FFT_N;
for (i = (FFT_N >> 1) - 1; i > 0; i--) {
theta = i * dt;
costable[i] = Math.cos(theta);
sintable[i] = Math.sin(theta);
}
};
/*
用于频谱显示的快速傅里叶变换
inBuffer 输入FFT_N个实数返回 FFT_N/2个输出值(复数模的平方)
*/
var getModulus = function (inBuffer) {
var i,
j,
k,
ir,
j0 = 1,
idx = FFT_N_LOG - 1;
var cosv, sinv, tmpr, tmpi;
for (i = 0; i != FFT_N; i++) {
real[i] = inBuffer[bitReverse[i]];
imag[i] = 0;
}
for (i = FFT_N_LOG; i != 0; i--) {
for (j = 0; j != j0; j++) {
cosv = costable[j << idx];
sinv = sintable[j << idx];
for (k = j; k < FFT_N; k += j0 << 1) {
ir = k + j0;
tmpr = cosv * real[ir] - sinv * imag[ir];
tmpi = cosv * imag[ir] + sinv * real[ir];
real[ir] = real[k] - tmpr;
imag[ir] = imag[k] - tmpi;
real[k] += tmpr;
imag[k] += tmpi;
}
}
j0 <<= 1;
idx--;
}
j = FFT_N >> 1;
var outBuffer = new Float64Array(j);
/*
* 输出模的平方:
* for(i = 1; i <= j; i++)
* inBuffer[i-1] = real[i] * real[i] + imag[i] * imag[i];
*
* 如果FFT只用于频谱显示,可以"淘汰"幅值较小的而减少浮点乘法运算. MINY的值
* 和Spectrum.Y0,Spectrum.logY0对应.
*/
sinv = MINY;
cosv = -MINY;
for (i = j; i != 0; i--) {
tmpr = real[i];
tmpi = imag[i];
if (tmpr > cosv && tmpr < sinv && tmpi > cosv && tmpi < sinv)
outBuffer[i - 1] = 0;
else outBuffer[i - 1] = Math.round(tmpr * tmpr + tmpi * tmpi);
}
return outBuffer;
};
FFT_Fn(bufferSize);
return { transform: getModulus, bufferSize: FFT_N };
};
export default genFFT

10943
src/lib/html2canvas.esm.js Normal file

File diff suppressed because one or more lines are too long

15
src/main.ts Normal file
View File

@ -0,0 +1,15 @@
import { createSSRApp } from 'vue'
import App from './App.vue'
import plugins from './plugins'
import router from './router'
import './styles/index.scss'
import { setupMixin } from './mixins'
export function createApp() {
const app = createSSRApp(App)
setupMixin(app)
app.use(plugins)
app.use(router)
return {
app
}
}

172
src/manifest.json Normal file
View File

@ -0,0 +1,172 @@
{
"name" : "码多多AI",
"appid" : "__UNI__2A068A4",
"description" : "",
"versionName" : "3.2.1",
"versionCode" : 201,
"transformPx" : false,
/* 5+App */
"app-plus" : {
"compatible" : {
"ignoreVersion" : true //trueHBuilderX1.9.0
},
"usingComponents" : true,
"nvueStyleCompiler" : "uni-app",
"compilerVersion" : 3,
"splashscreen" : {
"alwaysShowBeforeRender" : true,
"waiting" : true,
"autoclose" : true,
"delay" : 0
},
/* */
"modules" : {
"Payment" : {},
"OAuth" : {},
"Share" : {}
},
/* */
"distribute" : {
/* android */
"android" : {
"permissions" : [
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_COARSE_LOCATION\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
"<uses-permission android:name=\"android.permission.INTERNET\"/>",
"<uses-permission android:name=\"android.permission.MODIFY_AUDIO_SETTINGS\"/>",
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.WRITE_EXTERNAL_STORAGE\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
],
"abiFilters" : [ "armeabi-v7a", "arm64-v8a", "x86" ]
},
/* ios */
"ios" : {
"dSYMs" : false,
"privacyDescription" : {
"NSCameraUsageDescription" : "您可以拍照设置头像、拍照上传图片",
"NSPhotoLibraryAddUsageDescription" : "您可以设置头像、保存图片到相册,还可以上传图片",
"NSPhotoLibraryUsageDescription" : "您可以设置头像、保存图片到相册,还可以上传图片",
"NSUserTrackingUsageDescription" : "根据您的习惯为您推荐"
},
"capabilities" : {
"entitlements" : {
"com.apple.developer.associated-domains" : [
"applinks:static-mp-62a38312-a6b8-4502-9a4c-9bb095d26ddd.next.bspapp.com"
]
}
}
},
/* SDK */
"sdkConfigs" : {
"payment" : {
"weixin" : {
"__platform__" : [ "ios", "android" ],
"appid" : "wxee7d4a85331633e4",
"UniversalLinks" : "https://static-mp-62a38312-a6b8-4502-9a4c-9bb095d26ddd.next.bspapp.com/uni-universallinks/__UNI__1FC79BE/"
},
"alipay" : {
"__platform__" : [ "ios", "android" ]
}
},
"ad" : {},
"oauth" : {
"weixin" : {
"appid" : "wxee7d4a85331633e4",
"UniversalLinks" : "https://static-mp-62a38312-a6b8-4502-9a4c-9bb095d26ddd.next.bspapp.com/uni-universallinks/__UNI__1FC79BE/"
}
},
"share" : {
"weixin" : {
"appid" : "wxee7d4a85331633e4",
"UniversalLinks" : "https://static-mp-62a38312-a6b8-4502-9a4c-9bb095d26ddd.next.bspapp.com/uni-universallinks/__UNI__1FC79BE/"
}
}
},
"icons" : {
"android" : {
"hdpi" : "unpackage/res/icons/72x72.png",
"xhdpi" : "unpackage/res/icons/96x96.png",
"xxhdpi" : "unpackage/res/icons/144x144.png",
"xxxhdpi" : "unpackage/res/icons/192x192.png"
},
"ios" : {
"appstore" : "unpackage/res/icons/1024x1024.png",
"ipad" : {
"app" : "unpackage/res/icons/76x76.png",
"app@2x" : "unpackage/res/icons/152x152.png",
"notification" : "unpackage/res/icons/20x20.png",
"notification@2x" : "unpackage/res/icons/40x40.png",
"proapp@2x" : "unpackage/res/icons/167x167.png",
"settings" : "unpackage/res/icons/29x29.png",
"settings@2x" : "unpackage/res/icons/58x58.png",
"spotlight" : "unpackage/res/icons/40x40.png",
"spotlight@2x" : "unpackage/res/icons/80x80.png"
},
"iphone" : {
"app@2x" : "unpackage/res/icons/120x120.png",
"app@3x" : "unpackage/res/icons/180x180.png",
"notification@2x" : "unpackage/res/icons/40x40.png",
"notification@3x" : "unpackage/res/icons/60x60.png",
"settings@2x" : "unpackage/res/icons/58x58.png",
"settings@3x" : "unpackage/res/icons/87x87.png",
"spotlight@2x" : "unpackage/res/icons/80x80.png",
"spotlight@3x" : "unpackage/res/icons/120x120.png"
}
}
},
"splashscreen" : {
"useOriginalMsgbox" : true
}
}
},
/* */
"quickapp" : {},
/* */
"mp-weixin" : {
"appid" : "wx386a75e518b38935",
"optimization" : {
"subPackages" : true
},
"setting" : {
"urlCheck" : false,
"es6" : true,
"minified" : true
},
"__usePrivacyCheck__" : true,
"usingComponents" : true
},
"mp-alipay" : {
"usingComponents" : true
},
"mp-baidu" : {
"usingComponents" : true
},
"mp-toutiao" : {
"usingComponents" : true
},
"uniStatistics" : {
"enable" : false
},
"vueVersion" : "3",
"h5" : {
"router" : {
"mode" : "history",
"base" : "/mobile/"
},
"title" : "加载中"
},
"_spaceID" : "mp-62a38312-a6b8-4502-9a4c-9bb095d26ddd"
}

7
src/mixins/index.ts Normal file
View File

@ -0,0 +1,7 @@
import { App } from 'vue'
import share from './share'
import theme from './theme'
import setTitle from './setTitle'
export function setupMixin(app: App) {
app.mixin(share).mixin(theme).mixin(setTitle)
}

8
src/mixins/setTitle.ts Normal file
View File

@ -0,0 +1,8 @@
import { useNavigationBarTitleStore } from '@/stores/navigationBarTitle'
export default {
onShow() {
const navigationBarTitleStore = useNavigationBarTitleStore()
navigationBarTitleStore.setTitle()
}
}

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