!13 更新到master分支

Merge pull request !13 from nongyehong/dev
This commit is contained in:
nongyehong 2024-06-21 10:55:03 +00:00 committed by Gitee
commit 156bb4ea28
No known key found for this signature in database
GPG Key ID: 173E9B9CA92EEF8F
83 changed files with 5608 additions and 1978 deletions

View File

@ -1,5 +1,7 @@
# 后端服务地址
VITE_SERVICE_URL="http://localhost:9095"
VITE_SERVICE_URL="http://127.0.0.1:9190"
# websocket服务地址
VITE_WEBSOCKET_URL="ws://127.0.0.1:8090"
# 项目标题
VITE_APP_TITLE="HuLa—IM"
# 标签后缀

4
.gitignore vendored
View File

@ -22,7 +22,3 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# 忽略自动导入的文件
/src/typings/auto-imports.d.ts
/src/typings/components.d.ts

44
build/config/console.ts Normal file
View File

@ -0,0 +1,44 @@
import pkg from '../../package.json'
/**
*
* @param env
* @param mode
*/
export const atStartup = (env: { [key: string]: string }, mode: string) => {
return () => {
if (mode === 'dev') {
console.log(
` 🍀 ${'\x1b[32m'}${'\x1b[38;2;19;152;127m'}${env.VITE_APP_NAME} ${'\x1b[0m'}${'\x1b[90m'}${pkg.version}${'\x1b[0m'}`
)
console.log(
` ${'\u001b[38;2;26;178;146m'}${'\x1b[1m'}${'➜'}${'\x1b[0m'} ` +
`${'当前环境: '}` +
`${'\x1b[38;2;215;95;135m'}${mode}${'\x1b[0m'}`
)
console.log(
` ${'\u001b[38;2;26;178;146m'}${'\x1b[1m'}${'➜'}${'\x1b[0m'} ${'\x1b[38;2;0;215;175m'}${'\x1b[1m'}${'Vue'}${'\x1b[0m'}: ` +
`${'\x1b[90m'}${pkg.dependencies.vue}${'\x1b[0m'}` +
` ${'\x1b[38;2;95;215;255m'}${'\x1b[1m'}${'Vite'}${'\x1b[0m'}: ` +
`${'\x1b[90m'}${pkg.devDependencies.vite}${'\x1b[0m'}` +
` ${'\u001b[38;5;33m'}${'\x1b[1m'}${'Node.js'}${'\x1b[0m'}: ` +
`${'\x1b[90m'}${process.version}${'\x1b[0m'}`
)
console.log(
` ${'\u001b[38;2;26;178;146m'}${'\x1b[1m'}${'➜'}${'\x1b[0m'} ` +
`当前 ${'\x1b[38;2;215;135;0m'}${'\x1b[1m'}Tauri${'\x1b[0m'} 版本: `,
`${'\x1b[90m'}${pkg.dependencies['@tauri-apps/api']}${'\x1b[0m'}`
)
console.log(
` ${'\u001b[38;2;26;178;146m'}${'\x1b[1m'}${'\u001b[2m'}${'➜'}${'\x1b[0m'} ` +
'后端服务地址: ' +
`${'\x1b[38;2;95;175;255m'}https://github.com/nongyehong/HuLa-IM-Server${'\x1b[0m'}`
)
console.log(
` ${'\u001b[38;2;26;178;146m'}${'\x1b[1m'}${'\u001b[2m'}${'➜'}${'\x1b[0m'} ` +
'项目地址: ' +
`${pkg.author.url}`
)
}
}
}

42
build/config/version.ts Normal file
View File

@ -0,0 +1,42 @@
import pkg from '../../package.json'
/** npm依赖包版本信息 */
export interface PkgVersionInfo {
name: string
version: string
}
interface Package {
name: string
version: string
author: Record<string, string>
dependencies: Record<string, string>
devDependencies: Record<string, string>
[key: string]: any
}
interface PkgJson {
name: string
version: string
author: Record<string, string>
dependencies: PkgVersionInfo[]
devDependencies: PkgVersionInfo[]
}
const pkgWithType = pkg as Package
const transformVersionData = (tuple: [string, string]): PkgVersionInfo => {
const [name, version] = tuple
return {
name,
version
}
}
export const pkgJson: PkgJson = {
name: pkgWithType.name,
version: pkgWithType.version,
author: pkgWithType.author,
dependencies: Object.entries(pkgWithType.dependencies).map((item) => transformVersionData(item)),
devDependencies: Object.entries(pkgWithType.devDependencies).map((item) => transformVersionData(item))
}

View File

@ -1,4 +1,13 @@
/** @type {import('cz-git').UserConfig} */
const fs = require('fs')
const path = require('path')
/* 获取项目的修改范围 */
const scopes = fs
.readdirSync(path.resolve(__dirname, 'src'), { withFileTypes: true })
.filter((dirent) => dirent.isDirectory())
.map((dirent) => dirent.name.replace(/s$/, ''))
module.exports = {
// 继承的规则
extends: ["@commitlint/config-conventional"],
@ -61,12 +70,10 @@ module.exports = {
useAI: false,
aiNumber: 1,
themeColorCode: "38;5;168",
scopes: [],
allowCustomScopes: true,
allowEmptyScopes: true,
customScopesAlign: "bottom",
customScopesAlias: "custom",
emptyScopesAlias: "empty",
customScopesAlign: 'bottom',
scopes: [...scopes],
upperCaseSubject: false,
markBreakingChangeMode: false,
allowBreakingChanges: ["feat", "fix"],
@ -88,7 +95,6 @@ module.exports = {
scopeOverrides: undefined,
defaultBody: "",
defaultIssues: "",
defaultScope: "",
defaultSubject: "",
},
};

View File

@ -7,7 +7,7 @@
<title>HuLa</title>
<!--引入iconpark图标库-->
<script defer src="https://lf1-cdn-tos.bytegoofy.com/obj/iconpark/svg_30895_78.2ef5ae05e210de3f66b0fe5c58a7a130.js"></script>
<script defer src="https://lf1-cdn-tos.bytegoofy.com/obj/iconpark/svg_30895_81.5cb4aedb87267e96f93088d7f30024fe.js"></script>
</head>
<body>

View File

@ -26,7 +26,7 @@
"scripts": {
"dev": "vite --mode dev",
"prod": "vite --mode prod",
"build": "vue-tsc --noEmit && vite build --mode dev",
"build": "vue-tsc --noEmit && vite build --mode pord",
"preview": "vite preview",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
@ -38,17 +38,18 @@
"prepare": "husky install"
},
"dependencies": {
"@tauri-apps/api": "^1.5.3",
"@tauri-apps/api": "^1.5.6",
"axios": "^1.6.8",
"colorthief": "^2.4.0",
"dayjs": "^1.11.10",
"grapheme-splitter": "^1.0.4",
"lodash-es": "^4.17.21",
"mitt": "^3.0.1",
"naive-ui": "^2.38.1",
"naive-ui": "^2.38.2",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.1",
"pinia-shared-state": "^0.5.1",
"vue": "^3.4.23",
"vue": "^3.4.27",
"vue-draggable-plus": "^0.4.0",
"vue-router": "^4.3.0"
},
@ -57,22 +58,22 @@
"@commitlint/cli": "^19.2.2",
"@commitlint/config-conventional": "^19.2.2",
"@rollup/plugin-terser": "^0.4.4",
"@tauri-apps/cli": "^1.5.11",
"@tauri-apps/cli": "1.5.14",
"@types/lodash-es": "^4.17.12",
"@types/node": "^20.10.4",
"@typescript-eslint/eslint-plugin": "7.1.0",
"@typescript-eslint/parser": "^7.1.0",
"@unocss/preset-uno": "^0.59.4",
"@unocss/reset": "^0.59.4",
"@unocss/transformer-directives": "^0.59.4",
"@unocss/transformer-variant-group": "^0.59.4",
"@unocss/vite": "^0.59.4",
"@unocss/preset-uno": "^0.61.0",
"@unocss/reset": "^0.61.0",
"@unocss/transformer-directives": "^0.61.0",
"@unocss/transformer-variant-group": "^0.61.0",
"@unocss/vite": "^0.61.0",
"@vitejs/plugin-vue": "^5.0.4",
"@vitejs/plugin-vue-jsx": "^3.1.0",
"@vitejs/plugin-vue-jsx": "^4.0.0",
"@vueuse/core": "^10.8.0",
"commitizen": "^4.3.0",
"conventional-changelog": "^5.1.0",
"conventional-changelog-cli": "^4.1.0",
"conventional-changelog": "^6.0.0",
"conventional-changelog-cli": "^5.0.0",
"cz-git": "^1.9.1",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
@ -85,10 +86,10 @@
"prettier": "^3.2.5",
"sass": "^1.71.1",
"sass-loader": "^14.1.1",
"typescript": "^5.4.3",
"typescript": "^5.4.5",
"unplugin-auto-import": "^0.17.2",
"unplugin-vue-components": "^0.26.0",
"vite": "5.2.9",
"vite": "5.3.1",
"vue-tsc": "^2.0.6"
},
"config": {

File diff suppressed because it is too large Load Diff

480
src-tauri/Cargo.lock generated
View File

@ -127,7 +127,7 @@ dependencies = [
"polling 2.8.0",
"rustix 0.37.27",
"slab",
"socket2",
"socket2 0.4.10",
"waker-fn",
]
@ -393,6 +393,9 @@ name = "bytes"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223"
dependencies = [
"serde",
]
[[package]]
name = "cairo-rs"
@ -866,7 +869,7 @@ dependencies = [
"rustc_version",
"toml 0.8.8",
"vswhom",
"winreg",
"winreg 0.51.0",
]
[[package]]
@ -1488,6 +1491,25 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "h2"
version = "0.3.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8"
dependencies = [
"bytes",
"fnv",
"futures-core",
"futures-sink",
"futures-util",
"http",
"indexmap 2.1.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@ -1515,6 +1537,12 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.3.3"
@ -1527,20 +1555,6 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "html5ever"
version = "0.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5c13fb08e5d4dfc151ee5e88bae63f7773d61852f3bdc73c9f4b9e1bde03148"
dependencies = [
"log",
"mac",
"markup5ever 0.10.1",
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "html5ever"
version = "0.26.0"
@ -1549,7 +1563,7 @@ checksum = "bea68cab48b8459f17cf1c944c67ddc572d272d9f2b274140f223ecb1da4a3b7"
dependencies = [
"log",
"mac",
"markup5ever 0.11.0",
"markup5ever",
"proc-macro2",
"quote",
"syn 1.0.109",
@ -1566,12 +1580,35 @@ dependencies = [
"itoa 1.0.10",
]
[[package]]
name = "http-body"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2"
dependencies = [
"bytes",
"http",
"pin-project-lite",
]
[[package]]
name = "http-range"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
[[package]]
name = "httparse"
version = "1.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hula"
version = "1.5.0"
@ -1585,6 +1622,43 @@ dependencies = [
"window-vibrancy",
]
[[package]]
name = "hyper"
version = "0.14.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf96e135eb83a2a8ddf766e426a841d8ddd7449d5f00d34ea02b41d2f19eef80"
dependencies = [
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"httparse",
"httpdate",
"itoa 1.0.10",
"pin-project-lite",
"socket2 0.5.7",
"tokio",
"tower-service",
"tracing",
"want",
]
[[package]]
name = "hyper-tls"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
dependencies = [
"bytes",
"hyper",
"native-tls",
"tokio",
"tokio-native-tls",
]
[[package]]
name = "iana-time-zone"
version = "0.1.58"
@ -1733,6 +1807,12 @@ dependencies = [
"windows-sys 0.48.0",
]
[[package]]
name = "ipnet"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3"
[[package]]
name = "itoa"
version = "0.4.8"
@ -1809,18 +1889,6 @@ dependencies = [
"treediff",
]
[[package]]
name = "kuchiki"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ea8e9c6e031377cff82ee3001dc8026cdf431ed4e2e6b51f98ab8c73484a358"
dependencies = [
"cssparser",
"html5ever 0.25.2",
"matches",
"selectors",
]
[[package]]
name = "kuchikiki"
version = "0.8.2"
@ -1828,7 +1896,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f29e4755b7b995046f510a7520c42b2fed58b77bd94d5a87a8eb43d2fd126da8"
dependencies = [
"cssparser",
"html5ever 0.26.0",
"html5ever",
"indexmap 1.9.3",
"matches",
"selectors",
@ -1971,20 +2039,6 @@ dependencies = [
"libc",
]
[[package]]
name = "markup5ever"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a24f40fb03852d1cdd84330cddcaf98e9ec08a7b7768e952fad3b4cf048ec8fd"
dependencies = [
"log",
"phf 0.8.0",
"phf_codegen 0.8.0",
"string_cache",
"string_cache_codegen",
"tendril",
]
[[package]]
name = "markup5ever"
version = "0.11.0"
@ -2038,6 +2092,12 @@ dependencies = [
"autocfg",
]
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "miniz_oxide"
version = "0.7.1"
@ -2048,6 +2108,35 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "mio"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [
"libc",
"wasi 0.11.0+wasi-snapshot-preview1",
"windows-sys 0.48.0",
]
[[package]]
name = "native-tls"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e"
dependencies = [
"lazy_static",
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "ndk"
version = "0.6.0"
@ -2248,6 +2337,50 @@ dependencies = [
"windows-sys 0.42.0",
]
[[package]]
name = "openssl"
version = "0.10.64"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f"
dependencies = [
"bitflags 2.4.1",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.41",
]
[[package]]
name = "openssl-probe"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
[[package]]
name = "openssl-sys"
version = "0.9.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c597637d56fbc83893a35eb0dd04b2b8e7a50c91e64e9493e398b5df4fb45fa2"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "ordered-stream"
version = "0.2.0"
@ -2806,6 +2939,48 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f"
[[package]]
name = "reqwest"
version = "0.11.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62"
dependencies = [
"base64 0.21.5",
"bytes",
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"http",
"http-body",
"hyper",
"hyper-tls",
"ipnet",
"js-sys",
"log",
"mime",
"native-tls",
"once_cell",
"percent-encoding",
"pin-project-lite",
"rustls-pemfile",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"system-configuration",
"tokio",
"tokio-native-tls",
"tokio-util",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"winreg 0.50.0",
]
[[package]]
name = "rfd"
version = "0.10.0"
@ -2872,6 +3047,15 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rustls-pemfile"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c"
dependencies = [
"base64 0.21.5",
]
[[package]]
name = "rustversion"
version = "1.0.14"
@ -2899,6 +3083,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534"
dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "scoped-tls"
version = "1.0.1"
@ -2911,6 +3104,29 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "770452e37cad93e0a50d5abc3990d2bc351c36d0328f86cefec2f2fb206eaef6"
dependencies = [
"bitflags 1.3.2",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f3cc463c0ef97e11c3461a9d3787412d30e8e7eb907c79180c4a57bf7c04ef"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "selectors"
version = "0.22.0"
@ -2992,6 +3208,18 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa 1.0.10",
"ryu",
"serde",
]
[[package]]
name = "serde_with"
version = "3.4.0"
@ -3130,6 +3358,16 @@ dependencies = [
"winapi",
]
[[package]]
name = "socket2"
version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "soup2"
version = "0.2.1"
@ -3233,6 +3471,12 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160"
[[package]]
name = "sys-locale"
version = "0.2.4"
@ -3246,6 +3490,27 @@ dependencies = [
"windows-sys 0.45.0",
]
[[package]]
name = "system-configuration"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7"
dependencies = [
"bitflags 1.3.2",
"core-foundation",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "system-deps"
version = "5.0.0"
@ -3351,11 +3616,12 @@ checksum = "14c39fd04924ca3a864207c66fc2cd7d22d7c016007f9ce846cbb9326331930a"
[[package]]
name = "tauri"
version = "1.6.1"
version = "1.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f078117725e36d55d29fafcbb4b1e909073807ca328ae8deb8c0b3843aac0fed"
checksum = "77567d2b3b74de4588d544147142d02297f3eaa171a25a065252141d8597a516"
dependencies = [
"anyhow",
"bytes",
"cocoa 0.24.1",
"dirs-next",
"dunce",
@ -3363,13 +3629,15 @@ dependencies = [
"encoding_rs",
"flate2",
"futures-util",
"getrandom 0.2.11",
"glib",
"glob",
"gtk",
"heck 0.4.1",
"heck 0.5.0",
"http",
"ico 0.2.0",
"ignore",
"indexmap 1.9.3",
"infer 0.9.0",
"nix",
"notify-rust",
@ -3382,6 +3650,7 @@ dependencies = [
"rand 0.8.5",
"raw-window-handle",
"regex",
"reqwest",
"rfd",
"semver",
"serde",
@ -3407,14 +3676,14 @@ dependencies = [
[[package]]
name = "tauri-build"
version = "1.5.0"
version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "defbfc551bd38ab997e5f8e458f87396d2559d05ce32095076ad6c30f7fc5f9c"
checksum = "ab30cba12974d0f9b09794f61e72cad6da2142d3ceb81e519321bab86ce53312"
dependencies = [
"anyhow",
"cargo_toml",
"dirs-next",
"heck 0.4.1",
"heck 0.5.0",
"json-patch",
"semver",
"serde",
@ -3426,9 +3695,9 @@ dependencies = [
[[package]]
name = "tauri-codegen"
version = "1.4.2"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1554c5857f65dbc377cefb6b97c8ac77b1cb2a90d30d3448114d5d6b48a77fc"
checksum = "c3a1d90db526a8cdfd54444ad3f34d8d4d58fa5c536463915942393743bd06f8"
dependencies = [
"base64 0.21.5",
"brotli",
@ -3452,11 +3721,11 @@ dependencies = [
[[package]]
name = "tauri-macros"
version = "1.4.3"
version = "1.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "277abf361a3a6993ec16bcbb179de0d6518009b851090a01adfea12ac89fa875"
checksum = "6a582d75414250122e4a597b9dd7d3c910a2c77906648fc2ac9353845ff0feec"
dependencies = [
"heck 0.4.1",
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 1.0.109",
@ -3466,9 +3735,9 @@ dependencies = [
[[package]]
name = "tauri-runtime"
version = "0.14.2"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf2d0652aa2891ff3e9caa2401405257ea29ab8372cce01f186a5825f1bd0e76"
checksum = "cd7ffddf36d450791018e63a3ddf54979b9581d9644c584a5fb5611e6b5f20b4"
dependencies = [
"gtk",
"http",
@ -3487,9 +3756,9 @@ dependencies = [
[[package]]
name = "tauri-runtime-wry"
version = "0.14.5"
version = "0.14.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "067c56fc153b3caf406d7cd6de4486c80d1d66c0f414f39e94cb2f5543f6445f"
checksum = "1989b3b4d611f5428b3414a4abae6fa6df30c7eb8ed33250ca90a5f7e5bb3655"
dependencies = [
"cocoa 0.24.1",
"gtk",
@ -3507,16 +3776,16 @@ dependencies = [
[[package]]
name = "tauri-utils"
version = "1.5.3"
version = "1.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75ad0bbb31fccd1f4c56275d0a5c3abdf1f59999f72cb4ef8b79b4ed42082a21"
checksum = "450b17a7102e5d46d4bdabae0d1590fd27953e704e691fc081f06c06d2253b35"
dependencies = [
"brotli",
"ctor",
"dunce",
"glob",
"heck 0.4.1",
"html5ever 0.26.0",
"heck 0.5.0",
"html5ever",
"infer 0.13.0",
"json-patch",
"kuchikiki",
@ -3667,8 +3936,11 @@ checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931"
dependencies = [
"backtrace",
"bytes",
"libc",
"mio",
"num_cpus",
"pin-project-lite",
"socket2 0.5.7",
"tokio-macros",
"windows-sys 0.48.0",
]
@ -3684,6 +3956,30 @@ dependencies = [
"syn 2.0.41",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
"tracing",
]
[[package]]
name = "toml"
version = "0.5.11"
@ -3752,6 +4048,12 @@ dependencies = [
"winnow",
]
[[package]]
name = "tower-service"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52"
[[package]]
name = "tracing"
version = "0.1.40"
@ -3822,6 +4124,12 @@ dependencies = [
"serde_json",
]
[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typenum"
version = "1.17.0"
@ -3899,6 +4207,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.0.11"
@ -3953,6 +4267,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "want"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
dependencies = [
"try-lock",
]
[[package]]
name = "wasi"
version = "0.9.0+wasi-snapshot-preview1"
@ -4031,6 +4354,19 @@ version = "0.2.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ab9b36309365056cd639da3134bf87fa8f3d86008abf99e612384a6eecd459f"
[[package]]
name = "wasm-streams"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b65dc4c90b63b118468cf747d8bf3566c1913ef60be765b5730ead9e0a3ba129"
dependencies = [
"futures-util",
"js-sys",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "web-sys"
version = "0.3.66"
@ -4560,6 +4896,16 @@ dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.50.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1"
dependencies = [
"cfg-if",
"windows-sys 0.48.0",
]
[[package]]
name = "winreg"
version = "0.51.0"
@ -4572,9 +4918,9 @@ dependencies = [
[[package]]
name = "wry"
version = "0.24.6"
version = "0.24.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64a70547e8f9d85da0f5af609143f7bde3ac7457a6e1073104d9b73d6c5ac744"
checksum = "00711278ed357350d44c749c286786ecac644e044e4da410d466212152383b45"
dependencies = [
"base64 0.13.1",
"block",
@ -4586,9 +4932,9 @@ dependencies = [
"gio",
"glib",
"gtk",
"html5ever 0.25.2",
"html5ever",
"http",
"kuchiki",
"kuchikiki",
"libc",
"log",
"objc",

View File

@ -17,10 +17,10 @@ strip = true # 删除调试符号
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "1.5", features = [] }
tauri-build = { version = "1.5.2", features = [] }
[dependencies]
tauri = { version = "1.6.1", features = [ "os-all", "process-all", "notification-all", "macos-private-api", "fs-all", "window-all", "system-tray", "shell-open", "icon-png", "icon-ico"] }
tauri = { version = "1.6.8", features = [ "http-all", "os-all", "process-all", "notification-all", "macos-private-api", "fs-all", "window-all", "system-tray", "shell-open", "icon-png", "icon-ico"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
window-shadows = "0.2.2" # windows 阴影和圆角

View File

@ -8,13 +8,13 @@ pub fn set_window_attribute<R: Runtime>(app: &tauri::App<R>) {
#[cfg(any(windows, target_os = "macos"))]
set_shadow(&window, true).unwrap();
// 设置窗口的磨砂背景
#[cfg(target_os = "macos")]
window_vibrancy::apply_acrylic(&window, Some((255, 255, 255, 1)))
.expect("Unsupported platform! 'apply_vibrancy' is only supported on macOS");
#[cfg(target_os = "windows")]
window_vibrancy::apply_acrylic(&window, Some((255, 255, 255, 1)))
.expect("Unsupported platform! 'apply_blur' is only supported on Windows");
// // 设置窗口的磨砂背景
// #[cfg(target_os = "macos")]
// window_vibrancy::apply_acrylic(&window, Some((255, 255, 255, 1)))
// .expect("Unsupported platform! 'apply_vibrancy' is only supported on macOS");
//
// #[cfg(target_os = "windows")]
// window_vibrancy::apply_acrylic(&window, Some((255, 255, 255, 1)))
// .expect("Unsupported platform! 'apply_blur' is only supported on Windows");
}
}

View File

@ -32,7 +32,9 @@
"open": true
},
"http": {
"scope": ["http://**", "https://**"]
"all": true,
"request": true,
"scope": ["http://127.0.0.1:9190/*", "https://**"]
},
"fs": {
"all": true,

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24"><circle cx="12" cy="2" r="0" fill="#666666"><animate attributeName="r" begin="0" calcMode="spline" dur="1s" keySplines="0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8" repeatCount="indefinite" values="0;2;0;0"/></circle><circle cx="12" cy="2" r="0" fill="#666666" transform="rotate(45 12 12)"><animate attributeName="r" begin="0.125s" calcMode="spline" dur="1s" keySplines="0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8" repeatCount="indefinite" values="0;2;0;0"/></circle><circle cx="12" cy="2" r="0" fill="#666666" transform="rotate(90 12 12)"><animate attributeName="r" begin="0.25s" calcMode="spline" dur="1s" keySplines="0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8" repeatCount="indefinite" values="0;2;0;0"/></circle><circle cx="12" cy="2" r="0" fill="#666666" transform="rotate(135 12 12)"><animate attributeName="r" begin="0.375s" calcMode="spline" dur="1s" keySplines="0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8" repeatCount="indefinite" values="0;2;0;0"/></circle><circle cx="12" cy="2" r="0" fill="#666666" transform="rotate(180 12 12)"><animate attributeName="r" begin="0.5s" calcMode="spline" dur="1s" keySplines="0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8" repeatCount="indefinite" values="0;2;0;0"/></circle><circle cx="12" cy="2" r="0" fill="#666666" transform="rotate(225 12 12)"><animate attributeName="r" begin="0.625s" calcMode="spline" dur="1s" keySplines="0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8" repeatCount="indefinite" values="0;2;0;0"/></circle><circle cx="12" cy="2" r="0" fill="#666666" transform="rotate(270 12 12)"><animate attributeName="r" begin="0.75s" calcMode="spline" dur="1s" keySplines="0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8" repeatCount="indefinite" values="0;2;0;0"/></circle><circle cx="12" cy="2" r="0" fill="#666666" transform="rotate(315 12 12)"><animate attributeName="r" begin="0.875s" calcMode="spline" dur="1s" keySplines="0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8" repeatCount="indefinite" values="0;2;0;0"/></circle></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

2
src/common/group.ts Normal file
View File

@ -0,0 +1,2 @@
// 最大管理员数量
export const MAX_ADMIN_COUNT = 3

12
src/common/message.ts Normal file
View File

@ -0,0 +1,12 @@
import { MsgEnum } from '@/enums'
// 消息回复映射表
export const MSG_REPLY_TEXT_MAP: Record<number, string> = {
[MsgEnum.UNKNOWN]: '[未知]',
[MsgEnum.RECALL]: '[撤回消息]',
[MsgEnum.IMAGE]: '[图片]',
[MsgEnum.FILE]: '[文件]',
[MsgEnum.VOICE]: '[语音]',
[MsgEnum.VIDEO]: '[音频]',
[MsgEnum.EMOJI]: '[表情]'
}

View File

@ -18,9 +18,14 @@
align="center"
justify="space-between"
class="emoji-list">
<n-flex :size="0" align="center" justify="center" class="emoji-item" @click="handleReplyEmoji(item)">
{{ item.label }}
</n-flex>
<n-popover trigger="hover" :show-arrow="false" placement="top">
<template #trigger>
<n-flex :size="0" align="center" justify="center" class="emoji-item" @click="handleReplyEmoji(item)">
{{ item.label }}
</n-flex>
</template>
<span>{{ item.title }}</span>
</n-popover>
</n-flex>
</div>
<!-- 普通右键菜单 -->

View File

@ -22,6 +22,15 @@
<span class="text-[--info-text-color]">所在地</span>
<span>中国</span>
</n-flex>
<!-- 获得的徽章 -->
<n-flex v-if="isCurrentUser.itemIds && isCurrentUser.itemIds.length > 0" :size="26" class="select-none">
<span class="text-[--info-text-color]">获得的徽章</span>
<n-flex>
<template v-for="id in isCurrentUser.itemIds" :key="id">
<img class="size-38px" :src="useBadgeInfo(id).value.img" alt="" />
</template>
</n-flex>
</n-flex>
<!-- 动态 -->
<n-flex :size="40" class="select-none">
<span class="text-[--info-text-color]">动态</span>
@ -45,24 +54,19 @@
<!-- 背景 -->
<img
class="size-full rounded-8px box-border p-20px absolute top-0 left-0"
style="filter: blur(28px); opacity: 0.8"
class="size-full rounded-8px box-border p-20px absolute top-0 left-0 blur-xl opacity-80"
:src="isCurrentUser.avatar"
alt="" />
</n-flex>
</template>
<script setup lang="ts">
import { MockItem } from '@/services/types.ts'
import { setting } from '@/stores/setting.ts'
import { storeToRefs } from 'pinia'
import { useBadgeInfo, useUserInfo } from '@/hooks/useCached.ts'
const { info } = defineProps<{
info?: MockItem
const { uid } = defineProps<{
uid: number
}>()
const settingStore = setting()
const { login } = storeToRefs(settingStore)
const isCurrentUser = computed(() => (info ? info : login.value.accountInfo))
const isCurrentUser = computed(() => useUserInfo(uid).value)
</script>
<style scoped lang="scss">

View File

@ -8,8 +8,10 @@
<n-dialog-provider>
<n-notification-provider :max="notificMax">
<n-message-provider :max="messageMax">
<slot></slot>
<naive-provider-content />
<n-modal-provider>
<slot></slot>
<naive-provider-content />
</n-modal-provider>
</n-message-provider>
</n-notification-provider>
</n-dialog-provider>
@ -135,6 +137,7 @@ const registerNaiveTools = () => {
window.$dialog = useDialog()
window.$message = useMessage()
window.$notification = useNotification()
window.$modal = useModal()
}
const NaiveProviderContent = defineComponent({

View File

@ -1,6 +1,6 @@
<template>
<!-- 好友详情 -->
<n-flex v-if="item.type === RoomTypeEnum.SINGLE" vertical align="center" :size="30" class="mt-60px select-none">
<n-flex v-if="content.type === RoomTypeEnum.SINGLE" vertical align="center" :size="30" class="mt-60px select-none">
<n-image
width="146px"
height="146px"
@ -9,13 +9,23 @@
:src="item.avatar"
alt="" />
<span class="text-(20px [--text-color])">{{ item.accountName }}</span>
<span class="text-(20px [--text-color])">{{ item.name }}</span>
<span class="text-(14px #909090)">这个人很高冷,暂时没有留下什么</span>
<n-flex align="center" justify="space-between" :size="30" class="text-#606060">
<span>性别</span>
<span>电话13213213213</span>
<span>地区{{ item.locPlace || '未知' }}</span>
<n-flex align="center">
<span>徽章</span>
<template v-for="badge in item.itemIds" :key="badge">
<n-popover trigger="hover">
<template #trigger>
<img class="size-34px" :src="useBadgeInfo(badge).value.img" alt="" />
</template>
<span>{{ useBadgeInfo(badge).value.describe }}</span>
</n-popover>
</template>
</n-flex>
</n-flex>
<!-- 选项按钮 -->
<n-config-provider :theme="lightTheme">
@ -55,7 +65,7 @@
alt="" />
<n-flex vertical :size="16" justify="space-between" class="text-(14px #909090)">
<span class="text-(16px [--text-color])">{{ item.accountName }}</span>
<span class="text-(16px [--text-color])">{{ item.name }}</span>
<span>群号1235873897182</span>
<span>创建时间2021-01-01</span>
</n-flex>
@ -97,20 +107,25 @@
</div>
</template>
<script setup lang="ts">
import { MockItem } from '@/services/types.ts'
import { MittEnum, RoomTypeEnum } from '@/enums'
import { lightTheme } from 'naive-ui'
import router from '@/router'
import Mitt from '@/utils/Bus.ts'
import { useMessage } from '@/hooks/useMessage.ts'
import { useBadgeInfo, useUserInfo } from '@/hooks/useCached.ts'
import apis from '@/services/apis.ts'
import { useGlobalStore } from '@/stores/global.ts'
import { useChatStore } from '@/stores/chat.ts'
const { handleMsgClick } = useMessage()
const globalStore = useGlobalStore()
const chatStore = useChatStore()
const props = defineProps<{
content: any[]
content: any
}>()
const { content } = toRefs(props)
const item = computed<MockItem>(() => {
return content.value[0]
const item = computed(() => {
return useUserInfo(content.value.uid).value
})
const footerOptions = ref<OPT.Details[]>([
@ -120,7 +135,12 @@ const footerOptions = ref<OPT.Details[]>([
click: () => {
// TODO (nyh -> 2024-03-25 16:01:23)
router.push('/message')
handleMsgClick(item.value)
apis.sessionDetailWithFriends({ uid: item.value.uid as number }).then((res) => {
globalStore.currentSession.roomId = res.data.roomId
globalStore.currentSession.type = RoomTypeEnum.SINGLE
chatStore.updateSessionLastActiveTime(res.data.roomId, res.data)
handleMsgClick(res.data as any)
})
Mitt.emit(MittEnum.TO_SEND_MSG, { url: 'message' })
}
},

View File

@ -15,20 +15,20 @@
</ContextMenu>
<!-- @提及框 -->
<div v-if="ait && activeItem.type === RoomTypeEnum.GROUP && filteredList.length > 0" class="ait">
<div v-if="ait && activeItem.type === RoomTypeEnum.GROUP && personList.length > 0" class="ait">
<n-virtual-list
id="image-chat-msgInput"
ref="virtualListInst"
style="max-height: 180px"
:item-size="36"
:items="filteredList"
:items="personList"
v-model:selectedKey="selectedAitKey">
<template #default="{ item }">
<n-flex
@mouseover="() => (selectedAitKey = item.key)"
:class="{ active: selectedAitKey === item.key }"
@mouseover="() => (selectedAitKey = item.uid)"
:class="{ active: selectedAitKey === item.uid }"
@click="handleAit(item)"
:key="item.key"
:key="item.uid"
align="center"
class="ait-item">
<n-avatar
@ -42,7 +42,7 @@
:intersection-observer-options="{
root: '#image-chat-msgInput'
}" />
<span> {{ item.accountName }}</span>
<span> {{ item.name }}</span>
</n-flex>
</template>
</n-virtual-list>
@ -80,7 +80,7 @@
import { lightTheme, darkTheme, VirtualListInst } from 'naive-ui'
import { MittEnum, RoomTypeEnum, ThemeEnum } from '@/enums'
import Mitt from '@/utils/Bus.ts'
import { MockItem } from '@/services/types.ts'
import { CacheUserItem, MockItem } from '@/services/types.ts'
import { emit, listen } from '@tauri-apps/api/event'
import { setting } from '@/stores/setting.ts'
import { storeToRefs } from 'pinia'
@ -93,14 +93,14 @@ const settingStore = setting()
const { themes } = storeToRefs(settingStore)
/** 发送按钮旁的箭头 */
const arrow = ref(false)
// dom
/** 输入框dom元素 */
const messageInputDom = ref()
const activeItem = ref(inject('activeItem') as MockItem)
/** 虚拟列表 */
const virtualListInst = ref<VirtualListInst>()
const { handlePaste } = useCommon()
/** 引入useMsgInput的相关方法 */
const { inputKeyDown, handleAit, handleInput, send, filteredList, ait, msgInput, chatKey, menuList, selectedAitKey } =
const { inputKeyDown, handleAit, handleInput, send, personList, ait, msgInput, chatKey, menuList, selectedAitKey } =
useMsgInput(messageInputDom)
/** 当切换聊天对象时,重新获取焦点 */
@ -112,19 +112,19 @@ watch(activeItem, () => {
})
/** 当ait人员列表发生变化的时候始终select第一个 */
watch(filteredList, (newList) => {
watch(personList, (newList) => {
if (newList.length > 0) {
/** 先设置滚动条滚动到第一个 */
virtualListInst.value?.scrollTo({ key: newList[0].key })
selectedAitKey.value = newList[0].key
virtualListInst.value?.scrollTo({ key: newList[0].uid })
selectedAitKey.value = newList[0].uid
}
})
/** 处理键盘上下键切换提及项 */
const handleAitKeyChange = (direction: 1 | -1) => {
const currentIndex = filteredList.value.findIndex((item) => item.key === selectedAitKey.value)
const newIndex = Math.max(0, Math.min(currentIndex + direction, filteredList.value.length - 1))
selectedAitKey.value = filteredList.value[newIndex].key
const currentIndex = personList.value.findIndex((item) => item.uid === selectedAitKey.value)
const newIndex = Math.max(0, Math.min(currentIndex + direction, personList.value.length - 1))
selectedAitKey.value = personList.value[newIndex].uid
// (使key)
virtualListInst.value?.scrollTo({ key: selectedAitKey.value })
}
@ -140,7 +140,7 @@ onMounted(() => {
onKeyStroke('Enter', (e) => {
if (ait.value && selectedAitKey.value > -1) {
e.preventDefault()
const item = filteredList.value.find((item) => item.key === selectedAitKey.value) as MockItem
const item = personList.value.find((item) => item.uid === selectedAitKey.value) as CacheUserItem
handleAit(item)
}
})

View File

@ -3,7 +3,12 @@
<main
style="box-shadow: 0 4px 4px var(--box-shadow-color)"
class="relative z-30 flex-y-center justify-between p-[8px_20px_12px] select-none">
<div class="color-[--text-color]">{{ activeItem.accountName }}</div>
<n-flex align="center">
<span class="color-[--text-color]">{{ activeItem.name }}</span>
<svg v-if="activeItem.hot_Flag === IsAllUserEnum.Yes" class="size-20px color-#13987f select-none outline-none">
<use href="#auth"></use>
</svg>
</n-flex>
<!-- 顶部右边选项栏 -->
<nav class="options flex-y-center gap-20px color-[--icon-color]">
<div class="options-box">
@ -114,7 +119,7 @@
</template>
<script setup lang="ts">
import { MockItem } from '@/services/types.ts'
import { IsAllUserEnum, SessionItem } from '@/services/types.ts'
import { useDisplayMedia } from '@vueuse/core'
import { EventEnum } from '@/enums'
import { emit, listen } from '@tauri-apps/api/event'
@ -129,7 +134,7 @@ const modalShow = ref(false)
const sidebarShow = ref(false)
const masking = ref(false)
const { activeItem } = defineProps<{
activeItem: MockItem
activeItem: SessionItem
}>()
// RTCPeerConnection

View File

@ -1,4 +1,12 @@
<template>
<div class="absolute-x-center top-20px" v-if="!messageOptions?.isLoading && chatMessageList?.length === 0">
<span v-if="chatStore.isGroup" class="text-(14px #909090)">暂无消息快来发送第一条消息吧~</span>
<span v-else class="text-(14px #909090)">你们已成功添加为好友现在可以开始聊天了!</span>
</div>
<n-flex justify="center" class="absolute-x-center top-10px" v-if="messageOptions?.isLoading">
<img class="size-16px" src="@/assets/img/loading-one.svg" alt="" />
<span class="text-(14px #909090)">加载中</span>
</n-flex>
<!-- 中间聊天内容(使用虚拟列表) -->
<n-virtual-list
id="image-chat-main"
@ -7,35 +15,73 @@
style="max-height: calc(100vh - 260px)"
:class="{ 'right-1px': activeItem.type === RoomTypeEnum.SINGLE }"
class="relative h-100vh"
item-resizable
:ignore-item-resize="true"
:padding-top="10"
:item-size="itemSize"
:items="items">
<template #default="{ item }">
<main
:key="item.key"
class="flex-y-center min-h-58px"
:items="chatMessageList">
<template #default="{ item, index }">
<n-flex
vertical
:key="index"
class="flex-y-center"
:class="[
[isGroup ? 'p-[18px_20px]' : 'chat-single p-[4px_20px_10px_20px]'],
{ 'active-reply': activeReply === item.key }
item.message.type === MsgEnum.RECALL ? 'min-h-22px' : 'min-h-62px',
chatStore.isGroup ? 'p-[14px_20px]' : 'chat-single p-[4px_20px_10px_20px]',
{ 'active-reply': activeReply === item.message.id }
]">
<!-- 信息间隔时间 -->
<span class="text-(12px #909090) select-none bg-[--time-color] p-4px rounded-6px" v-if="item.timeBlock">
{{ item.timeBlock }}
</span>
<!-- 消息为撤回消息 -->
<div v-if="item.message.type === MsgEnum.RECALL">
<template v-if="chatStore.isGroup">
<span v-if="item.fromUser.uid === userUid" class="text-12px color-#909090 select-none">
你撤回了一条消息
</span>
<span v-else class="text-12px color-#909090 select-none" v-html="item.message.body"></span>
</template>
<template v-else>
<span class="text-12px color-#909090 select-none">
{{ item.fromUser.uid === userUid ? '你撤回了一条消息' : '对方撤回了一条消息' }}
</span>
</template>
</div>
<!-- 好友或者群聊的信息 -->
<article
class="flex flex-col w-full gap-18px"
:class="{
'items-end': item.accountId === userId
}">
<div class="flex items-start flex-1" :class="item.accountId === userId ? 'flex-row-reverse' : ''">
v-else
class="flex flex-col w-full"
:class="[{ 'items-end': item.fromUser.uid === userUid }, chatStore.isGroup ? 'gap-18px' : 'gap-2px']">
<!-- 信息时间(单聊) -->
<div
v-if="!chatStore.isGroup"
class="text-(12px #909090) h-12px w-fit select-none"
:class="
item.fromUser.uid === userUid
? activeReply === item.message.id
? 'pr-68px'
: 'pr-42px'
: activeReply === item.message.id
? 'pl-68px'
: 'pl-42px'
">
<Transition name="fade">
<span v-if="hoverBubble.key === item.message.id">
{{ formatTimestamp(item.message.sendTime, true) }}
</span>
</Transition>
</div>
<div class="flex items-start flex-1" :class="item.fromUser.uid === userUid ? 'flex-row-reverse' : ''">
<!-- 回复消息提示的箭头 -->
<svg
v-if="activeReply === item.key"
v-if="activeReply === item.message.id"
class="size-16px pt-4px color-#909090"
:class="item.accountId === userId ? 'ml-8px' : 'mr-8px'">
<use :href="item.accountId === userId ? `#corner-down-left` : `#corner-down-right`"></use>
:class="item.fromUser.uid === userUid ? 'ml-8px' : 'mr-8px'">
<use :href="item.fromUser.uid === userUid ? `#corner-down-left` : `#corner-down-right`"></use>
</svg>
<!-- 头像 -->
<n-popover
@update:show="handlePopoverUpdate(item.key)"
@update:show="handlePopoverUpdate(item.message.id)"
trigger="click"
placement="right"
:show-arrow="false"
@ -44,45 +90,94 @@
<template #trigger>
<ContextMenu
@select="$event.click(item, 'Main')"
:menu="isGroup ? optionsList : void 0"
:menu="chatStore.isGroup ? optionsList : void 0"
:special-menu="report">
<n-avatar
lazy
round
:color="'#fff'"
:size="34"
@click="selectKey = item.key"
@click="selectKey = item.message.id"
class="select-none"
:src="item.accountId === userId ? item.avatar : activeItem.avatar"
:class="item.accountId === userId ? '' : 'mr-10px'"
fallback-src="/logo.png"
:render-placeholder="() => null"
:intersection-observer-options="{
root: '#image-chat-main'
}"></n-avatar>
:src="
item.fromUser.uid === userUid
? login.accountInfo.avatar
: useUserInfo(item.fromUser.uid).value.avatar
"
:class="item.fromUser.uid === userUid ? '' : 'mr-10px'"></n-avatar>
</ContextMenu>
</template>
<!-- 用户个人信息框 -->
<InfoPopover v-if="selectKey === item.key" :info="item.accountId !== userId ? activeItemRef : void 0" />
<InfoPopover v-if="selectKey === item.message.id" :uid="item.fromUser.uid" />
</n-popover>
<n-flex
vertical
justify="center"
:size="8"
:size="6"
class="color-[--text-color] flex-1"
:class="item.accountId === userId ? 'items-end mr-10px' : ''">
<ContextMenu @select="$event.click(item)" :menu="isGroup ? optionsList : []" :special-menu="report">
<span class="text-12px select-none color-#909090" v-if="isGroup">
{{ item.value }}
</span>
</ContextMenu>
:class="item.fromUser.uid === userUid ? 'items-end mr-10px' : ''">
<n-flex
:size="4"
align="center"
:style="item.fromUser.uid === userUid ? 'flex-direction: row-reverse' : ''">
<ContextMenu
@select="$event.click(item)"
:menu="chatStore.isGroup ? optionsList : []"
:special-menu="report">
<n-flex
:size="6"
class="select-none"
align="center"
v-if="chatStore.isGroup"
:style="item.fromUser.uid === userUid ? 'flex-direction: row-reverse' : ''">
<!-- 用户徽章 -->
<n-popover trigger="hover">
<template #trigger>
<img
v-if="useBadgeInfo(useUserInfo(item.fromUser.uid).value.wearingItemId).value.img"
class="size-18px"
:src="useBadgeInfo(useUserInfo(item.fromUser.uid).value.wearingItemId).value.img"
alt="badge" />
</template>
<span>{{ useBadgeInfo(useUserInfo(item.fromUser.uid).value.wearingItemId).value.describe }}</span>
</n-popover>
<!-- 用户名 -->
<span class="text-12px select-none color-#909090 inline-block align-top">
{{ useUserInfo(item.fromUser.uid).value.name }}
</span>
<!-- 消息归属地 -->
<span class="text-(12px #909090)">
({{ useUserInfo(item.fromUser.uid).value.locPlace || '未知' }})
</span>
</n-flex>
</ContextMenu>
<!-- 群主 -->
<div
v-if="chatStore.isGroup && item.message.id === 1"
class="flex p-4px rounded-4px bg-#f5dadf size-fit select-none">
<span class="text-(10px #d5304f)">群主</span>
</div>
<!-- 管理员 -->
<div
v-if="chatStore.isGroup && item.message.id === 2"
class="flex p-4px rounded-4px bg-#13987F66 size-fit select-none">
<span class="text-(10px #13987f)">管理员</span>
</div>
<!-- 信息时间(群聊) -->
<Transition name="fade">
<span v-if="chatStore.isGroup && hoverBubble.key === item.message.id" class="text-(12px #909090)">
{{ formatTimestamp(item.message.sendTime, true) }}
</span>
</Transition>
</n-flex>
<!-- 气泡样式 -->
<ContextMenu
class="size-fit"
:data-key="item.accountId === userId ? `U${item.key}` : `Q${item.key}`"
@mouseenter="handleMouseEnter(item.message.id)"
@mouseleave="handleMouseLeave"
class="w-fit"
:data-key="item.fromUser.uid === userUid ? `U${item.message.id}` : `Q${item.message.id}`"
@select="$event.click(item)"
:menu="handleItemType(item.type)"
:emoji="isGroup ? emojiList : []"
:menu="handleItemType(item.message.type)"
:emoji="chatStore.isGroup ? emojiList : []"
:special-menu="specialMenuList"
@reply-emoji="handleEmojiSelect($event.label, item)"
@click="handleMsgClick(item)">
@ -90,13 +185,13 @@
<!-- <RenderMessage :message="message" />-->
<!-- 消息为文本类型或者回复消息 -->
<div
v-if="item.type === MsgEnum.TEXT || item.type === MsgEnum.REPLY"
v-if="item.message.type === MsgEnum.TEXT"
style="white-space: pre-wrap"
:class="[
{ active: activeBubble === item.key },
item.accountId === userId ? 'bubble-oneself' : 'bubble'
{ active: activeBubble === item.message.id },
item.fromUser.uid === userUid ? 'bubble-oneself' : 'bubble'
]">
<span v-html="item.content"></span>
<span v-html="item.message.body.content"></span>
</div>
<!-- 消息为为图片类型(不固定宽度和高度), 多张图片时渲染 -->
@ -106,7 +201,7 @@
class="select-none"
v-for="(src, index) in item.content"
:key="index"
:img-props="{ style: { maxWidth: '325px', maxHeight: '165px' } }"
:img-props="{ style: { maxWidth: '325px', maxHeight: '165px', width: '100%', height: 'auto' } }"
show-toolbar-tooltip
style="border-radius: 8px"
:fallback-src="'https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg'"
@ -118,7 +213,7 @@
<n-image
class="select-none"
v-if="typeof item.content === 'string' && item.type === MsgEnum.IMAGE"
:img-props="{ style: { maxWidth: '325px', maxHeight: '165px' } }"
:img-props="{ style: { maxWidth: '325px', maxHeight: '165px', width: '100%', height: 'auto' } }"
show-toolbar-tooltip
style="border-radius: 8px"
:fallback-src="'https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg'"
@ -139,32 +234,32 @@
<n-flex
align="center"
:size="6"
v-if="item.reply && item.type === MsgEnum.REPLY"
@click="jumpToReplyMsg(item.reply.key)"
class="reply-bubble relative">
v-if="item.message.body.reply"
@click="jumpToReplyMsg(item.message.body.reply.id)"
class="reply-bubble relative w-fit">
<svg class="size-14px"><use href="#to-top"></use></svg>
<span>{{ `${item.reply.accountName}` }}</span>
<span>{{ `${item.message.body.reply.username}` }}</span>
<!-- 当回复消息为图片时渲染 -->
<n-image
v-if="item.reply.content.startsWith('data:image/')"
:img-props="{ style: { maxWidth: '50px', maxHeight: '50px' } }"
show-toolbar-tooltip
style="border-radius: 4px"
@click.stop
:fallback-src="'https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg'"
:src="item.reply.content" />
<!-- <n-image-->
<!-- v-if="item.reply.content.startsWith('data:image/')"-->
<!-- :img-props="{ style: { maxWidth: '50px', maxHeight: '50px' } }"-->
<!-- show-toolbar-tooltip-->
<!-- style="border-radius: 4px"-->
<!-- @click.stop-->
<!-- :fallback-src="'https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg'"-->
<!-- :src="item.reply.content" />-->
<!-- 当回复消息为文本时渲染(判断是否有aitSpan标签) -->
<span v-else class="content-span">
{{ handleReply(item.reply.content) }}
<span class="content-span">
{{ handleReply(item.message.body.reply.body) }}
</span>
<!-- 多个图片时计数器样式 -->
<div v-if="item.reply.imgCount" class="reply-img-sub">
{{ item.reply.imgCount }}
</div>
<!-- &lt;!&ndash; 多个图片时计数器样式 &ndash;&gt;-->
<!-- <div v-if="item.reply.imgCount" class="reply-img-sub">-->
<!-- {{ item.reply.imgCount }}-->
<!-- </div>-->
</n-flex>
<!-- 群聊回复emoji表情 -->
<n-flex :size="4" v-if="isGroup && item.emojiList">
<n-flex :size="4" v-if="chatStore.isGroup && item.emojiList">
<n-flex
:size="2"
align="center"
@ -179,7 +274,7 @@
</n-flex>
</div>
</article>
</main>
</n-flex>
</template>
</n-virtual-list>
@ -201,19 +296,22 @@
</n-modal>
<!-- 悬浮按钮提示(头部悬浮) // TODO (nyh -> 2024-03-07 01:27:22)-->
<header class="float-header" :class="isGroup ? 'right-220px' : 'right-50px'">
<div class="float-box">
<n-flex justify="space-between" align="center">
<n-icon :color="'#13987f'">
<svg><use href="#double-up"></use></svg>
</n-icon>
<span class="text-12px">xx条新信息</span>
</n-flex>
</div>
</header>
<!-- <header class="float-header" :class="isGroup ? 'right-220px' : 'right-50px'">-->
<!-- <div class="float-box">-->
<!-- <n-flex justify="space-between" align="center">-->
<!-- <n-icon :color="'#13987f'">-->
<!-- <svg><use href="#double-up"></use></svg>-->
<!-- </n-icon>-->
<!-- <span class="text-12px">xx条新信息</span>-->
<!-- </n-flex>-->
<!-- </div>-->
<!-- </header>-->
<!-- 悬浮按钮提示(底部悬浮) -->
<footer class="float-footer" v-if="floatFooter && newMsgNum > 0" :class="isGroup ? 'right-220px' : 'right-50px'">
<footer
class="float-footer"
v-if="floatFooter && newMsgNum > 0"
:class="chatStore.isGroup ? 'right-220px' : 'right-50px'">
<div class="float-box" :class="{ max: newMsgNum > 99 }" @click="scrollBottom">
<n-flex justify="space-between" align="center">
<n-icon :color="newMsgNum > 99 ? '#ce304f' : '#13987f'">
@ -228,7 +326,7 @@
</template>
<script setup lang="ts">
import { EventEnum, MittEnum, MsgEnum, RoomTypeEnum } from '@/enums'
import { MockItem } from '@/services/types.ts'
import { type MessageType, SessionItem } from '@/services/types.ts'
import Mitt from '@/utils/Bus.ts'
import { invoke } from '@tauri-apps/api/tauri'
import { usePopover } from '@/hooks/usePopover.ts'
@ -240,75 +338,100 @@ import { delay } from 'lodash-es'
import { useCommon } from '@/hooks/useCommon.ts'
import { setting } from '@/stores/setting.ts'
import { storeToRefs } from 'pinia'
import { formatTimestamp } from '@/utils/ComputedTime.ts'
import { useUserInfo, useBadgeInfo } from '@/hooks/useCached.ts'
import { useChatStore } from '@/stores/chat.ts'
const { activeItem } = defineProps<{
activeItem: MockItem
activeItem: SessionItem
}>()
const activeItemRef = ref({ ...activeItem })
const activeItemRef = ref<SessionItem>({ ...activeItem })
const settingStore = setting()
const chatStore = useChatStore()
// const userInfo = useUserStore()?.userInfo
/** 消息列表 */
const chatMessageList = computed(() => chatStore.chatMessageList)
const messageOptions = computed(() => chatStore.currentMessageOptions)
const { login } = storeToRefs(settingStore)
const { createWebviewWindow } = useWindow()
/** 是否是超级管理员 */
// const isAdmin = computed(() => userInfo?.power === PowerEnum.ADMIN)
/** 跳转回复消息后选中效果 */
const activeReply = ref(-1)
/** 当前信息是否是群聊信息 */
const isGroup = computed(() => activeItem.type === RoomTypeEnum.GROUP)
/** item最小高度用于计算滚动大小和位置 */
const itemSize = computed(() => (isGroup.value ? 98 : 70))
const itemSize = computed(() => (chatStore.isGroup ? 90 : 76))
/** 虚拟列表 */
const virtualListInst = ref<VirtualListInst>()
/** 手动触发Popover显示 */
const infoPopover = ref(false)
const { removeTag } = useCommon()
/** 鼠标悬浮的气泡显示对应的时间 */
const hoverBubble = ref<{
key: number
timer?: NodeJS.Timeout
}>({
key: -1
})
const { removeTag, userUid } = useCommon()
const {
handleScroll,
handleMsgClick,
handleConfirm,
handleItemType,
items,
activeBubble,
newMsgNum,
floatFooter,
historyIndex,
tips,
modalShow,
userId,
specialMenuList,
itemComputed,
optionsList,
report,
selectKey,
emojiList
emojiList,
scrollTop
} = useChatMain(activeItem)
const { handlePopoverUpdate } = usePopover(selectKey, 'image-chat-main')
// // TextBody
// const textBody = {
// content: '123',
// reply: {
// /** */
// },
// urlContentMap: {
// // URL
// }
// }
//
// // 使 ref MsgType
// const msg = ref({
// id: 123456, // ID
// roomId: 123, // ID
// type: MsgEnum.TEXT, //
// body: textBody, // TextBody
// sendTime: Date.now(), //
// messageMark: {
// /** MessageMarkType */
// }
// })
// const message = computed(() => msg.value)
watchEffect(() => {
newMsgNum.value = itemComputed.value - historyIndex.value
activeItemRef.value = { ...activeItem }
watch(activeItemRef, (value, oldValue) => {
if (oldValue.roomId !== value.roomId) {
nextTick(() => {
console.log('切换房间')
virtualListInst.value?.scrollTo({ position: 'bottom', debounce: true })
})
}
})
watch(chatMessageList, (value, oldValue) => {
if (scrollTop.value === 0 && value.length > 20) {
nextTick(() => {
//
virtualListInst.value?.scrollTo({ index: value.length - oldValue.length, debounce: true })
})
} else {
nextTick(() => {
virtualListInst.value?.scrollTo({ position: 'bottom', debounce: true })
})
}
})
//
const handleMouseEnter = (key: any) => {
// 1600key
hoverBubble.value.timer = setTimeout(() => {
hoverBubble.value.key = key
}, 1600)
}
//
const handleMouseLeave = () => {
// undefined
if (hoverBubble.value.timer) {
clearTimeout(hoverBubble.value.timer)
hoverBubble.value.timer = void 0
}
// key-1
hoverBubble.value.key = -1
}
/** 取消回复emoji表情 */
const cancelReplyEmoji = (item: any, index: number) => {
// item.emojiListcount11count-1
@ -339,35 +462,6 @@ const handleReply = (content: string) => {
return content.includes('id="aitSpan"') ? removeTag(content) : content
}
/** 发送信息 */
const handleSendMessage = (msg: any) => {
nextTick(() => {
//
if (msg.type === MsgEnum.IMAGE || msg.type === MsgEnum.FILE) {
// img
const imgSrcArray = [...msg.content.matchAll(/<img.*?src="(.*?)"/g)].map((match) => match[1])
if (imgSrcArray.length > 1) {
// 1便使
msg.content = imgSrcArray
} else if (imgSrcArray.length === 1) {
// 1src
msg.content = imgSrcArray[0]
}
}
const index = items.value.length > 0 ? items.value[items.value.length - 1].key : 0
items.value.push({
value: login.value.accountInfo.name,
key: index + 1,
accountId: login.value.accountInfo.uid,
avatar: login.value.accountInfo.avatar,
content: msg.content,
type: msg.type,
reply: msg.type === MsgEnum.REPLY ? msg.reply : null
})
addToDomUpdateQueue(index, userId.value)
})
}
/** 跳转到回复消息 */
const jumpToReplyMsg = (key: number) => {
nextTick(() => {
@ -381,14 +475,14 @@ const jumpToReplyMsg = (key: number) => {
* @param index 下标
* @param id 用户ID
*/
const addToDomUpdateQueue = (index: number, id: string) => {
const addToDomUpdateQueue = (index: number, id: number) => {
// 使 nextTick
nextTick(() => {
if (!floatFooter.value || id === userId.value) {
if (!floatFooter.value || id === userUid.value) {
virtualListInst.value?.scrollTo({ position: 'bottom', debounce: true })
}
/** data-key标识的气泡,添加前缀用于区分用户消息,不然气泡动画会被覆盖 */
const dataKey = id === userId.value ? `U${index + 1}` : `Q${index + 1}`
const dataKey = id === userUid.value ? `U${index}` : `Q${index}`
const lastMessageElement = document.querySelector(`[data-key="${dataKey}"]`) as HTMLElement
if (lastMessageElement) {
//
@ -401,6 +495,7 @@ const addToDomUpdateQueue = (index: number, id: string) => {
lastMessageElement.addEventListener('animationend', handleAnimationEnd)
}
})
chatStore.clearNewMsgCount()
}
/** 点击后滚动到底部 */
@ -432,6 +527,10 @@ const closeMenu = (event: any) => {
}
onMounted(() => {
nextTick(() => {
//
virtualListInst.value?.scrollTo({ position: 'bottom', debounce: true })
})
/**! 启动图标闪烁 需要设置"resources": ["sec-tauri/图标放置的文件夹"]*/
invoke('tray_blink', {
isRun: true,
@ -441,8 +540,10 @@ onMounted(() => {
}).catch((error) => {
console.error('设置图标失败:', error)
})
Mitt.on(MittEnum.SEND_MESSAGE, (event: any) => {
handleSendMessage(event)
Mitt.on(MittEnum.SEND_MESSAGE, (event: MessageType) => {
nextTick(() => {
addToDomUpdateQueue(event.message.id, event.fromUser.uid)
})
})
Mitt.on(`${MittEnum.INFO_POPOVER}-Main`, (event: any) => {
selectKey.value = event
@ -475,6 +576,9 @@ onMounted(() => {
})
onUnmounted(() => {
clearTimeout(hoverBubble.value.timer)
hoverBubble.value.timer = void 0
hoverBubble.value.key = -1
window.removeEventListener('click', closeMenu, true)
})
</script>

View File

@ -1,8 +1,8 @@
<template>
<!--! 这里最好不要使用n-flex,滚动高度会有问题 -->
<main v-if="activeItem.type === RoomTypeEnum.GROUP" style="border-left: 1px solid var(--line-color)" class="item-box">
<main v-if="isGroup" style="border-left: 1px solid var(--line-color)" class="item-box">
<n-flex v-if="!isSearch" align="center" justify="space-between" class="pr-8px pl-8px h-42px">
<span class="text-14px">群聊成员</span>
<span class="text-14px">群聊成员&nbsp;{{ userList.length }}</span>
<svg @click="handleSearch" class="size-14px"><use href="#search"></use></svg>
</n-flex>
<!-- 搜索框 -->
@ -30,10 +30,10 @@
style="max-height: calc(100vh - 130px)"
item-resizable
:item-size="42"
:items="MockList">
:items="userList">
<template #default="{ item }">
<n-popover
@update:show="handlePopoverUpdate(item.key)"
@update:show="handlePopoverUpdate(item.uid)"
trigger="click"
placement="left"
:show-arrow="false"
@ -41,10 +41,12 @@
style="padding: 0; background: var(--bg-info); backdrop-filter: blur(10px)">
<template #trigger>
<ContextMenu @select="$event.click(item, 'Sidebar')" :menu="optionsList" :special-menu="report">
<n-flex @click="selectKey = item.key" :key="item.key" :size="10" align="center" class="item">
<n-flex @click="selectKey = item.uid" :key="item.uid" :size="10" align="center" class="item">
<n-avatar
lazy
round
class="grayscale"
:class="{ 'grayscale-0': item.activeStatus === OnlineEnum.ONLINE }"
:color="'#fff'"
:size="24"
:src="item.avatar"
@ -53,35 +55,54 @@
:intersection-observer-options="{
root: '#image-chat-sidebar'
}"></n-avatar>
<span class="text-12px">{{ item.accountName }}</span>
<span class="text-12px truncate flex-1">{{ item.name }}</span>
<div v-if="item.uid === 1" class="flex p-4px rounded-4px bg-#f5dadf size-fit select-none">
<span class="text-(10px #d5304f)">群主</span>
</div>
<div v-if="item.uid === 2" class="flex p-4px rounded-4px bg-#13987F66 size-fit select-none">
<span class="text-(10px #13987f)">管理员</span>
</div>
</n-flex>
</ContextMenu>
</template>
<!-- 用户个人信息框 -->
<InfoPopover v-if="selectKey === item.key" :info="item" />
<InfoPopover v-if="selectKey === item.uid" :uid="item.uid" />
</n-popover>
</template>
</n-virtual-list>
</main>
</template>
<script setup lang="ts">
import { MittEnum, RoomTypeEnum } from '@/enums'
import { MockItem } from '@/services/types.ts'
import { MockList } from '@/mock'
import { MittEnum, OnlineEnum, RoomTypeEnum } from '@/enums'
import { InputInst } from 'naive-ui'
import { usePopover } from '@/hooks/usePopover.ts'
import { useChatMain } from '@/hooks/useChatMain.ts'
import Mitt from '@/utils/Bus.ts'
import { useGroupStore } from '@/stores/group.ts'
import { useUserInfo } from '@/hooks/useCached.ts'
import { useGlobalStore } from '@/stores/global.ts'
import type { UserItem } from '@/services/types.ts'
const { activeItem } = defineProps<{
activeItem: MockItem
}>()
const groupStore = useGroupStore()
const globalStore = useGlobalStore()
const groupUserList = computed(() => groupStore.userList)
const userList = computed(() => {
return groupUserList.value.map((item: UserItem) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { uid, ...userInfo } = item // uid
return {
...userInfo,
...useUserInfo(item.uid).value
}
})
})
const isGroup = computed(() => globalStore.currentSession?.type === RoomTypeEnum.GROUP)
const isSearch = ref(false)
const searchRef = ref('')
/** 手动触发Popover显示 */
const infoPopover = ref(false)
const inputInstRef = ref<InputInst | null>(null)
const { optionsList, report, selectKey } = useChatMain(activeItem)
const { optionsList, report, selectKey } = useChatMain()
const { handlePopoverUpdate } = usePopover(selectKey, 'image-chat-sidebar')
const handleSearch = () => {

View File

@ -1,10 +1,10 @@
<template>
<!-- 头部 -->
<ChatHeader :active-item="activeItemRef" />
<ChatHeader :active-item="activeItemRef as any" />
<n-flex :size="0" class="h-full">
<n-flex vertical :size="0" class="flex-1">
<n-flex vertical :size="0" class="flex-1 relative">
<!-- 中间聊天框内容 -->
<ChatMain :active-item="activeItemRef" />
<ChatMain :active-item="activeItemRef as any" />
<!-- 输入框和操作列表 -->
<ChatFooter class="flex-1" />
</n-flex>

View File

@ -21,9 +21,13 @@ export enum RCodeEnum {
/**URL*/
export enum URLEnum {
/**用户*/
USER = '/user',
/**文章*/
ARTICLE = '/article'
USER = '/api/user',
/**聊天*/
CHAT = '/api/chat',
/**房间*/
ROOM = '/api/room',
/**oss*/
OSS = '/api/oss'
}
/** tauri原生跨窗口通信时传输的类型 */
@ -83,33 +87,94 @@ export enum StoresEnum {
/** 在线状态 */
ONLINE_STATUS = 'onlineStatus',
/** 历史内容 */
HISTORY = 'history'
HISTORY = 'history',
/** 聊天列表 */
CHAT_LIST = 'chatList'
}
/**
*
* todo: 后续需要补充
*/
export enum MsgEnum {
/** 未知 */
UNKNOWN,
/** 文本 */
TEXT,
/** 撤回 */
RECALL,
/** 图片 */
IMAGE,
/** 混合 */
MIXED,
/** 文件 */
FILE,
/** 语音 */
VOICE,
/** 视频 */
VIDEO,
/** 文件 */
FILE,
/** 表情 */
EMOTICON,
/** 表情包 */
EMOJI,
/** 系统消息 */
SYSTEM,
/** 混合 */
MIXED,
/** 艾特 */
AIT,
/** 回复 */
REPLY
}
/**
* 线
*/
export enum OnlineEnum {
/** 在线 */
ONLINE = 1,
/** 离线 */
OFFLINE
}
/**
*
*/
export enum ActEnum {
/** 确认 */
Confirm = 1,
/** 取消 */
Cancel
}
export enum SexEnum {
MAN = 1,
REMALE
}
export enum PowerEnum {
USER,
ADMIN
}
export enum IsYetEnum {
NO,
YES
}
export enum MarkEnum {
LIKE = 1,
DISLIKE
}
// 成员角色 1群主 2管理员 3普通成员 4踢出群聊
export enum RoleEnum {
/** 1群主 */
LORD = 1,
/** 2管理员 */
ADMIN,
/** 3普通成员 */
NORMAL,
/** 4踢出群聊 */
REMOVED
}
/** 房间类型 1群聊 2单聊 */
export enum RoomTypeEnum {
/** 1群聊 */
@ -118,6 +183,14 @@ export enum RoomTypeEnum {
SINGLE = 2
}
/** 变更类型 1 加入群组2 移除群组 */
export enum ChangeTypeEnum {
/** 1 加入群组 */
JOIN = 1,
/** 2 移除群组 */
REMOVE
}
/** 关闭窗口的行为 */
export enum CloseBxEnum {
/** 隐藏 */
@ -147,5 +220,7 @@ export enum WsResEnum {
/** 二维码登录 */
QRCODE_LOGIN = 'qrcodeLogin',
/** 登录成功 */
LOGIN_SUCCESS = 'loginSuccess'
LOGIN_SUCCESS = 'loginSuccess',
/** ws连接错误 */
WS_ERROR = 'wsError'
}

34
src/hooks/useCached.ts Normal file
View File

@ -0,0 +1,34 @@
import type { ComputedRef, Ref } from 'vue'
import { useCachedStore } from '@/stores/cached'
/**
* hook
* @param uid ID
* @description Hook后
*/
export const useUserInfo = (uid?: number | ComputedRef<number | undefined> | Ref<number>) => {
const cachedStore = useCachedStore()
const userInfo = computed(() => (uid && cachedStore.userCachedList[toValue(uid as number)]) || {})
// 如果没有就请求
const resultUid = toValue(uid as number)
if (resultUid && Object.keys(userInfo.value).length === 0) {
cachedStore.getBatchUserInfo([resultUid])
}
return userInfo
}
/**
* hook
* @param itemId ID
* @description Hook后
*/
export const useBadgeInfo = (itemId?: number | ComputedRef<number | undefined>) => {
const cachedStore = useCachedStore()
const badgeInfo = computed(() => (itemId && cachedStore.badgeCachedList[toValue(itemId as number)]) || {})
// 如果没有就请求
const resultItemId = toValue(itemId as number)
if (resultItemId && Object.keys(badgeInfo.value).length === 0) {
cachedStore.getBatchBadgeInfo([resultItemId])
}
return badgeInfo
}

View File

@ -1,18 +1,22 @@
import { useCommon } from '@/hooks/useCommon.ts'
import { MittEnum, MsgEnum } from '@/enums'
import { MockItem } from '@/services/types.ts'
import { MessageType, SessionItem } from '@/services/types.ts'
import Mitt from '@/utils/Bus.ts'
import { setting } from '@/stores/setting.ts'
import { storeToRefs } from 'pinia'
import { useChatStore } from '@/stores/chat.ts'
import apis from '@/services/apis.ts'
export const useChatMain = (activeItem: MockItem) => {
const { removeTag } = useCommon()
const settingStore = setting()
const { login } = storeToRefs(settingStore)
export const useChatMain = (activeItem?: SessionItem) => {
const { removeTag, userUid } = useCommon()
const chatStore = useChatStore()
// const userInfo = useUserStore()?.userInfo
// const chatMessageList = computed(() => chatStore.chatMessageList)
const messageOptions = computed(() => chatStore.currentMessageOptions)
/** 滚动条位置 */
const scrollTop = ref(-1)
/** 是否是超级管理员 */
// const isAdmin = computed(() => userInfo?.power === PowerEnum.ADMIN)
/** 选中的气泡消息 */
const activeBubble = ref(-1)
/** 当前登录的用户id */
const userId = ref(login.value.accountInfo.uid)
/** 提醒框标题 */
const tips = ref()
/** 是否显示删除信息的弹窗 */
@ -29,7 +33,7 @@ export const useChatMain = (activeItem: MockItem) => {
const selectKey = ref()
/** 计算出触发页脚后的历史消息下标 */
const itemComputed = computed(() => {
return items.value.filter((item) => item.accountId !== userId.value).length
return items.value.filter((item) => item.accountId !== userUid.value).length
})
/**! 模拟信息列表 */
@ -37,11 +41,11 @@ export const useChatMain = (activeItem: MockItem) => {
Array.from({ length: 5 }, (_, i) => ({
value: `${i}安老师`,
key: i,
accountId: activeItem.accountId,
avatar: activeItem.avatar,
accountId: activeItem?.roomId,
avatar: activeItem?.avatar,
content: '123',
type: MsgEnum.TEXT,
reply: MsgEnum.REPLY
reply: MsgEnum.RECALL
? {
accountName: '',
content: '',
@ -65,6 +69,14 @@ export const useChatMain = (activeItem: MockItem) => {
click: (item: any) => {
Mitt.emit(MittEnum.REPLY_MEG, item)
}
},
{
label: '撤回',
icon: 'corner-down-left',
click: async (item: MessageType) => {
await apis.recallMsg({ roomId: 1, msgId: item.message.id })
chatStore.updateRecallStatus({ msgId: item.message.id })
}
}
])
/** 右键消息菜单列表 */
@ -72,9 +84,8 @@ export const useChatMain = (activeItem: MockItem) => {
{
label: '复制',
icon: 'copy',
click: (item: any) => {
const content = items.value[item.key].content
handleCopy(content)
click: (item: MessageType) => {
handleCopy(item.message.body.content)
}
},
...commonMenuList.value
@ -175,16 +186,20 @@ export const useChatMain = (activeItem: MockItem) => {
/** emoji表情菜单 */
const emojiList = ref([
{
label: '👍'
label: '👍',
title: '好赞'
},
{
label: '😆'
label: '😆',
title: '开心'
},
{
label: '🥳'
label: '🥳',
title: '恭喜'
},
{
label: '🤯'
label: '🤯',
title: '惊呆了'
}
])
@ -222,23 +237,30 @@ export const useChatMain = (activeItem: MockItem) => {
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement
// 获取已滚动的距离,即从顶部到当前滚动位置的距离
const scrollTop = target.scrollTop
scrollTop.value = target.scrollTop
// 获取整个滚动容器的高度
const scrollHeight = target.scrollHeight
// 获取容器的可视区域高度
const clientHeight = target.clientHeight
// const scrollHeight = target.scrollHeight
// // 获取容器的可视区域高度
// const clientHeight = target.clientHeight
// 计算距离底部的距离
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
// 判断是否大于100
if (distanceFromBottom > 100) {
floatFooter.value = true
// 更新历史消息下标
historyIndex.value = itemComputed.value
} else {
floatFooter.value = false
historyIndex.value = 0
newMsgNum.value = 0
// const distanceFromBottom = scrollHeight - scrollTop.value - clientHeight
// 判断是否滚动到顶部
if (scrollTop.value === 0) {
// 记录顶部最后一条消息的下标
// historyIndex.value = chatMessageList.value[0].message.id
if (messageOptions.value?.isLoading) return
chatStore.loadMore()
}
// // 判断是否大于100
// if (distanceFromBottom > 100) {
// floatFooter.value = true
// // 更新历史消息下标
// historyIndex.value = itemComputed.value
// } else {
// floatFooter.value = false
// historyIndex.value = 0
// newMsgNum.value = 0
// }
}
/**
@ -258,12 +280,12 @@ export const useChatMain = (activeItem: MockItem) => {
}
/** 点击气泡消息时候监听用户是否按下ctrl+c来复制内容 */
const handleMsgClick = (item: any) => {
activeBubble.value = item.key
const handleMsgClick = (item: MessageType) => {
activeBubble.value = item.message.id
// 启用键盘监听
const handleKeyPress = (e: KeyboardEvent) => {
if (e.ctrlKey && e.key === 'c') {
const content = items.value[item.key].content
const content = items.value[item.message.id].content
handleCopy(content)
// 取消监听键盘事件,以免多次绑定
document.removeEventListener('keydown', handleKeyPress)
@ -285,12 +307,13 @@ export const useChatMain = (activeItem: MockItem) => {
historyIndex,
tips,
modalShow,
userId,
specialMenuList,
itemComputed,
optionsList,
report,
selectKey,
emojiList
emojiList,
commonMenuList,
scrollTop
}
}

View File

@ -2,9 +2,15 @@ import { LimitEnum, MsgEnum } from '@/enums'
import { Ref } from 'vue'
import { createFileOrVideoDom } from '@/utils/CreateDom.ts'
import { RegExp } from '@/utils/RegExp.ts'
import { setting } from '@/stores/setting.ts'
import { storeToRefs } from 'pinia'
/** 常用工具类 */
export const useCommon = () => {
const settingStore = setting()
const { login } = storeToRefs(settingStore)
/** 当前登录用户的uid */
const userUid = computed(() => login.value.accountInfo.uid)
/** 回复消息 */
const reply = ref({
accountName: '',
@ -34,7 +40,6 @@ export const useCommon = () => {
let hasImage = false
let hasVideo = false
let hasFile = false
let hasReply = false
const elements = messageInputDom.value.childNodes
for (const element of elements) {
@ -48,8 +53,6 @@ export const useCommon = () => {
}
} else if (element.tagName === 'VIDEO' || (element.tagName === 'A' && element.href.match(/\.(mp4|webm)$/i))) {
hasVideo = true
} else if (element.id === 'replyDiv') {
hasReply = true
}
}
@ -61,8 +64,6 @@ export const useCommon = () => {
return MsgEnum.MIXED
} else if (hasImage) {
return MsgEnum.IMAGE
} else if (hasReply) {
return MsgEnum.REPLY
} else {
return MsgEnum.TEXT
}
@ -153,15 +154,6 @@ export const useCommon = () => {
min-width: 0;
`
let contentBox
const { hyperlinkRegex, foundHyperlinks } = RegExp.isHyperlink(content)
// 判断是否包含超链接
if (foundHyperlinks && foundHyperlinks.length > 0) {
content.replace(hyperlinkRegex, (match: string) => {
reply.value.content = match.startsWith('www.') ? 'https://' + match : match
})
// 去掉content中的标签
content = removeTag(content)
}
// 判断content内容是否是data:image/开头的数组
if (Array.isArray(content)) {
// 获取总共有多少张图片
@ -192,6 +184,15 @@ export const useCommon = () => {
// 去掉content中的标签
content = removeTag(content)
}
const { hyperlinkRegex, foundHyperlinks } = RegExp.isHyperlink(content)
// 判断是否包含超链接
if (foundHyperlinks && foundHyperlinks.length > 0) {
content.replace(hyperlinkRegex, (match: string) => {
reply.value.content = match.startsWith('www.') ? 'https://' + match : match
})
// 去掉content中的标签
content = removeTag(content)
}
// 把正文放到span标签中并设置span标签的样式
contentBox = document.createElement('span')
contentBox.style.cssText = `
@ -208,6 +209,7 @@ export const useCommon = () => {
}
// 在回复信息的右边添加一个关闭信息的按钮
const closeBtn = document.createElement('span')
closeBtn.id = 'closeBtn'
closeBtn.style.cssText = `
display: flex;
align-items: center;
@ -352,6 +354,7 @@ export const useCommon = () => {
handlePaste,
removeTag,
FileOrVideoPaste,
reply
reply,
userUid
}
}

70
src/hooks/useDownload.ts Normal file
View File

@ -0,0 +1,70 @@
import { ref } from 'vue'
import { createEventHook } from '@vueuse/core'
export const useDownload = () => {
const process = ref(0)
const isDownloading = ref(false)
const { on: onLoaded, trigger } = createEventHook()
const getFileExtension = (url: string) => {
const pathname = new URL(url).pathname
const lastDotIndex = pathname.lastIndexOf('.')
if (lastDotIndex === -1) {
return ''
}
return pathname.slice(lastDotIndex + 1)
}
const getFileName = (url: string) => {
const pathname = new URL(url).pathname
const lastDotIndex = pathname.lastIndexOf('/')
if (lastDotIndex === -1) {
return '未知文件'
}
return pathname.slice(lastDotIndex + 1)
}
const downloadFile = (url: string, filename?: string, extension?: string) => {
isDownloading.value = true
const xhr = new XMLHttpRequest()
xhr.open('GET', url, true)
xhr.responseType = 'blob'
xhr.onprogress = (event) => {
if (event.lengthComputable) {
process.value = Math.floor((event.loaded / event.total) * 100)
}
}
xhr.onload = () => {
if (xhr.status === 200) {
const blob = new Blob([xhr.response], { type: 'application/octet-stream' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
const urlExtension = getFileExtension(url)
const ext = extension || urlExtension
if (filename) {
a.download = `${filename}${ext ? '.' : ''}${ext}`
} else {
a.download = getFileName(url)
}
a.click()
// 调用 URL.revokeObjectURL() 方法来释放该内存
URL.revokeObjectURL(a.href)
trigger('success')
} else {
trigger('fail')
}
// 清空进度
process.value = 0
isDownloading.value = false
}
xhr.send()
}
return {
onLoaded,
downloadFile,
process,
isDownloading
}
}

View File

@ -2,10 +2,9 @@ import { useWindow } from '@/hooks/useWindow.ts'
import { emit, listen } from '@tauri-apps/api/event'
import { EventEnum, MittEnum } from '@/enums'
import Mitt from '@/utils/Bus.ts'
import { MockItem } from '@/services/types.ts'
import { MockItem, SessionItem } from '@/services/types.ts'
import { delay } from 'lodash-es'
import { MockList } from '@/mock'
import { WebviewWindow } from '@tauri-apps/api/window'
import { setting } from '@/stores/setting.ts'
import { storeToRefs } from 'pinia'
@ -16,7 +15,7 @@ const msgBoxShow = ref(false)
/** 独立窗口的集合 */
const aloneWin = ref(new Set())
const shrinkStatus = ref(false)
const itemRef = ref({} as MockItem)
const itemRef = ref<SessionItem>()
export const useMessage = () => {
const settingStore = setting()
const { chat } = storeToRefs(settingStore)
@ -28,14 +27,14 @@ export const useMessage = () => {
})
/** 处理点击选中消息 */
const handleMsgClick = (item: MockItem) => {
const handleMsgClick = (item: SessionItem) => {
msgBoxShow.value = true
activeIndex.value = item.key
activeIndex.value = item.roomId
const data = { msgBoxShow, item }
Mitt.emit(MittEnum.MSG_BOX_SHOW, data)
// 判断是否打开了独立的窗口
if (aloneWin.value.has(EventEnum.ALONE + item.key)) {
checkWinExist(EventEnum.ALONE + item.key).then()
if (aloneWin.value.has(EventEnum.ALONE + item.roomId)) {
checkWinExist(EventEnum.ALONE + item.roomId).then()
activeIndex.value = -1
Mitt.emit(MittEnum.MSG_BOX_SHOW, { item: -1 })
}
@ -46,7 +45,7 @@ export const useMessage = () => {
}
/** 处理双击事件 */
const handleMsgDblclick = (item: MockItem) => {
const handleMsgDblclick = (item: SessionItem) => {
if (!chat.value.isDouble) return
delay(async () => {
await openAloneWin(item)
@ -54,17 +53,16 @@ export const useMessage = () => {
}
/** 打开独立窗口 */
const openAloneWin = async (item: MockItem) => {
const openAloneWin = async (item: SessionItem) => {
itemRef.value = { ...item }
if (activeIndex.value === item.key) {
if (activeIndex.value === item.roomId) {
activeIndex.value = -1
Mitt.emit(MittEnum.MSG_BOX_SHOW, { item: -1 })
await listen('aloneWin', () => {
emit('aloneData', { item: { ...item } })
})
}
// TODO 传递用户信息(这里的label最好使用用户唯一的id来代替) (nyh -> 2024-03-18 12:18:10)
await createWebviewWindow(item.accountName, EventEnum.ALONE + item.key, 720, 800, '', true, true, 580)
await createWebviewWindow(item.name, EventEnum.ALONE + item.roomId, 720, 800, '', true, true, 580)
}
const menuList = ref<OPT.RightMenu[]>([
@ -93,7 +91,7 @@ export const useMessage = () => {
{
label: '打开独立聊天窗口',
icon: 'freezing-line-column',
click: async (item: MockItem) => {
click: async (item: SessionItem) => {
await openAloneWin(item)
}
},
@ -104,30 +102,31 @@ export const useMessage = () => {
{
label: '从消息列表中移除',
icon: 'delete',
click: (item: MockItem) => {
// 根据key找到items中对应的下标
const index = MockList.value.findIndex((e) => e.key === item.key)
// 删除消息的时候判断是否已经打开了独立窗口
if (aloneWin.value.has(`alone${index}`)) {
const win = WebviewWindow.getByLabel(`alone${index}`)
win?.close()
}
// 如果找到了对应的元素,则移除
if (index !== -1) {
const removeItem = MockList.value.splice(index, 1)[0]
if (activeIndex.value === removeItem.key) {
if (index < MockList.value.length) {
// 需要使用新的索引位置找到key更新activeItem.value
activeIndex.value = MockList.value[index].key
handleMsgClick(MockList.value[index])
} else {
if (MockList.value.length === 0) return
// 如果我们删除的是最后一个元素,则需要选中前一个元素
activeIndex.value = MockList.value[MockList.value.length - 1].key
handleMsgClick(MockList.value[MockList.value.length - 1])
}
}
}
click: (item: SessionItem) => {
console.log(item)
// // 根据key找到items中对应的下标
// const index = MockList.value.findIndex((e) => e.key === item.key)
// // 删除消息的时候判断是否已经打开了独立窗口
// if (aloneWin.value.has(`alone${index}`)) {
// const win = WebviewWindow.getByLabel(`alone${index}`)
// win?.close()
// }
// // 如果找到了对应的元素,则移除
// if (index !== -1) {
// const removeItem = MockList.value.splice(index, 1)[0]
// if (activeIndex.value === removeItem.key) {
// if (index < MockList.value.length) {
// // 需要使用新的索引位置找到key更新activeItem.value
// activeIndex.value = MockList.value[index].key
// handleMsgClick(MockList.value[index])
// } else {
// if (MockList.value.length === 0) return
// // 如果我们删除的是最后一个元素,则需要选中前一个元素
// activeIndex.value = MockList.value[MockList.value.length - 1].key
// handleMsgClick(MockList.value[MockList.value.length - 1])
// }
// }
// }
}
},
{ label: '屏蔽此人消息', icon: 'forbid' }
@ -135,9 +134,9 @@ export const useMessage = () => {
onMounted(async () => {
await listen(EventEnum.ALONE, () => {
emit(EventEnum.ALONE + itemRef.value.key, itemRef.value)
if (aloneWin.value.has(EventEnum.ALONE + itemRef.value.key)) return
aloneWin.value.add(EventEnum.ALONE + itemRef.value.key)
emit(EventEnum.ALONE + itemRef.value?.roomId, itemRef.value)
if (aloneWin.value.has(EventEnum.ALONE + itemRef.value?.roomId)) return
aloneWin.value.add(EventEnum.ALONE + itemRef.value?.roomId)
})
await listen(EventEnum.WIN_CLOSE, (e) => {
aloneWin.value.delete(e.payload)

View File

@ -1,38 +1,50 @@
import { LimitEnum, MittEnum, MsgEnum } from '@/enums'
import { Ref } from 'vue'
import { MockItem } from '@/services/types.ts'
import { CacheUserItem } from '@/services/types.ts'
import { setting } from '@/stores/setting.ts'
import { storeToRefs } from 'pinia'
import { useDebounceFn } from '@vueuse/core'
import Mitt from '@/utils/Bus.ts'
import { MockList } from '@/mock'
import { useCommon } from './useCommon.ts'
import { RegExp } from '@/utils/RegExp.ts'
import apis from '@/services/apis.ts'
import { useGlobalStore } from '@/stores/global.ts'
import { useChatStore } from '@/stores/chat.ts'
import { useUserInfo } from '@/hooks/useCached.ts'
import { useCachedStore } from '@/stores/cached.ts'
export const useMsgInput = (messageInputDom: Ref) => {
const { triggerInputEvent, insertNode, getMessageContentType, getEditorRange, imgPaste, removeTag, reply } =
const chatStore = useChatStore()
const globalStore = useGlobalStore()
const cachedStore = useCachedStore()
const { triggerInputEvent, insertNode, getMessageContentType, getEditorRange, imgPaste, removeTag, reply, userUid } =
useCommon()
const settingStore = setting()
const { chat } = storeToRefs(settingStore)
const chatKey = ref(chat.value.sendKey)
const msgInput = ref('')
const ait = ref(false)
/** 临时消息id */
const tempMessageId = ref(0)
/** 艾特后的关键字的key */
const aitKey = ref('')
/** 是否正在输入拼音 */
const isChinese = ref(false)
// 记录编辑器光标的位置
const editorRange = ref<{ range: Range; selection: Selection } | null>(null)
// 过滤MockList
const filteredList = computed(() => {
/** @候选人列表 */
const personList = computed(() => {
if (aitKey.value && !isChinese.value) {
return MockList.value.filter((item) => item.accountName.includes(aitKey.value))
return cachedStore.currentAtUsersList.filter(
(user) => user.name?.startsWith(aitKey.value) && user.uid !== userUid.value
)
} else {
return MockList.value
// 过滤当前登录的用户
return cachedStore.currentAtUsersList.filter((user) => user.uid !== userUid.value)
}
})
/** 记录当前选中的提及项 key */
const selectedAitKey = ref(filteredList.value[0]?.key ?? null)
const selectedAitKey = ref(personList.value[0]?.uid ?? null)
/** 右键菜单列表 */
const menuList = ref([
{ label: '剪切', icon: 'screenshot', disabled: true },
@ -66,8 +78,8 @@ export const useMsgInput = (messageInputDom: Ref) => {
watchEffect(() => {
chatKey.value = chat.value.sendKey
if (!ait.value && filteredList.value.length > 0) {
selectedAitKey.value = 0
if (!ait.value && personList.value.length > 0) {
selectedAitKey.value = personList.value[0]?.uid
}
// 如果输入框没有值就把回复内容清空
if (msgInput.value === '') {
@ -91,15 +103,21 @@ export const useMsgInput = (messageInputDom: Ref) => {
})
/** 监听回复信息的传递 */
Mitt.on(MittEnum.REPLY_MEG, (event: any) => {
const accountName = useUserInfo(event.fromUser.uid).value.name!
// 如果已经有回复消息,则替换掉原来的回复消息
if (reply.value.content) {
// TODO 如果已经有就替换原来的内容 (nyh -> 2024-04-18 23:10:56)
return
// 触发id为closeBtn的按钮点击事件从而关闭第一个回复框实现回复消息的替换
document.getElementById('closeBtn')?.dispatchEvent(new Event('click'))
}
reply.value = { imgCount: 0, accountName: event.value, content: event.content, key: event.key }
if (!Array.isArray(event.message.body.content)) {
// 回复前把包含&nbsp;的字符替换成空格
event.message.body.content = event.message.body.content.replace(/&nbsp;/g, ' ')
}
reply.value = { imgCount: 0, accountName: accountName, content: event.message.body.content, key: event.key }
if (messageInputDom.value) {
nextTick().then(() => {
messageInputDom.value.focus()
insertNode(MsgEnum.REPLY, { accountName: event.value, content: event.content })
insertNode(MsgEnum.REPLY, { accountName: accountName, content: event.message.body.content })
triggerInputEvent(messageInputDom.value)
})
}
@ -114,27 +132,49 @@ export const useMsgInput = (messageInputDom: Ref) => {
window.$message.warning(`一次性只能上传${LimitEnum.COM_COUNT}个文件或图片`)
return
}
// 排除id="replyDiv"的元素的内容
const replyDiv = messageInputDom.value.querySelector('#replyDiv')
if (replyDiv) {
replyDiv.parentNode?.removeChild(replyDiv)
// 然后重新赋值给msgInput
msgInput.value = messageInputDom.value.innerHTML.replace(replyDiv.outerHTML, '')
}
ait.value = false
const contentType = getMessageContentType(messageInputDom)
const msg = {
type: contentType,
content: msgInput.value,
reply: contentType === MsgEnum.REPLY ? reply.value : null
content: removeTag(msgInput.value),
reply: reply.value
}
/** 如果是Reply消息需要将消息的样式修改 */
if (msg.type === MsgEnum.REPLY) {
// 先去掉原来的标签
msg.content = removeTag(msg.content)
// 截取空格后的内容
// TODO 不允许用户删除回复消息中最前面的空格或者标志符号 (nyh -> 2024-04-17 06:39:22)
msg.content = msg.content.replace(/^[\S\s]*\u00A0/, '')
// TODO 当输入的类型是艾特类型的时候需要处理 (nyh -> 2024-05-30 19:52:20)
// TODO 当输入的内容换行后会有div包裹这样会有xxr攻击风险 (nyh -> 2024-05-30 20:19:27)
/** 如果reply.value.content中有内容需要将消息的样式修改 */
if (reply.value.content) {
if (msg.type === MsgEnum.TEXT) {
// 创建一个虚拟div元素以便对HTML进行操作
const tempDiv = document.createElement('div')
// 将msg.content赋值给虚拟div的innerHTML
tempDiv.innerHTML = msg.content
// 查找id为"replyDiv"的元素
const replyDiv = tempDiv.querySelector('#replyDiv')
// 如果找到了元素,则删除它
if (replyDiv) {
replyDiv.parentNode?.removeChild(replyDiv)
}
// 先去掉原来的标签
tempDiv.innerHTML = removeTag(tempDiv.innerHTML)
// 只截取tempDiv.innerHTML开头中的&nbsp;
tempDiv.innerHTML = tempDiv.innerHTML.replace(/^\s*&nbsp;/, '')
// 处理后的内容可以传给实际发送消息的方法
msg.content = tempDiv.innerHTML
}
}
const { hyperlinkRegex, foundHyperlinks } = RegExp.isHyperlink(msg.content)
/** 判断是否有超链接 */
if (foundHyperlinks && foundHyperlinks.length > 0) {
msg.content = msg.content.replace(hyperlinkRegex, (match) => {
const href = match.startsWith('www.') ? 'https://' + match : match
return `<a style="color: inherit" href="${href}" target="_blank" rel="noopener noreferrer">${match}</a>`
return `<a style="color: inherit;text-underline-offset: 4px" href="${href}" target="_blank" rel="noopener noreferrer">${match}</a>`
})
}
// 判断文本信息是否超过限制
@ -147,7 +187,19 @@ export const useMsgInput = (messageInputDom: Ref) => {
window.$message.error('暂不支持混合类型消息发送')
return
}
Mitt.emit(MittEnum.SEND_MESSAGE, msg)
apis
.sendMsg({ roomId: globalStore.currentSession.roomId, msgType: msg.type, body: { content: msg.content } })
.then((res) => {
if (res.data.message.type === MsgEnum.TEXT) {
chatStore.pushMsg(res.data)
// 发完消息就要刷新会话列表,
// FIXME 如果当前会话已经置顶了,可以不用刷新
chatStore.updateSessionLastActiveTime(globalStore.currentSession.roomId)
} else {
// 更新上传状态下的消息
chatStore.updateMsg(tempMessageId.value, res.data)
}
})
msgInput.value = ''
messageInputDom.value.innerHTML = ''
reply.value = { imgCount: 0, accountName: '', content: '', key: '' }
@ -184,7 +236,7 @@ export const useMsgInput = (messageInputDom: Ref) => {
aitKey.value = keyWord
editorRange.value = { range, selection }
if (ait.value && filteredList.value.length > 0) {
if (ait.value && personList.value.length > 0) {
const res = range.getBoundingClientRect()
await nextTick(() => {
const dom = document.querySelector('.ait') as HTMLElement
@ -213,7 +265,7 @@ export const useMsgInput = (messageInputDom: Ref) => {
}
/** 处理点击@提及框事件 */
const handleAit = (item: MockItem) => {
const handleAit = (item: CacheUserItem) => {
const myEditorRange = editorRange?.value?.range
/** 获取光标所在位置的文本节点 */
const textNode = myEditorRange?.endContainer
@ -231,7 +283,7 @@ export const useMsgInput = (messageInputDom: Ref) => {
range?.setStart(textNode, <number>expRes?.index)
/** 设置范围的结束位置为光标的位置 */
range?.setEnd(textNode, endOffset!)
insertNode(MsgEnum.AIT, item.accountName)
insertNode(MsgEnum.AIT, item.name)
triggerInputEvent(messageInputDom.value)
ait.value = false
}
@ -242,7 +294,7 @@ export const useMsgInput = (messageInputDom: Ref) => {
handleAit,
handleInput,
send,
filteredList,
personList,
ait,
msgInput,
chatKey,

View File

@ -1,5 +1,6 @@
import { LogicalSize, WebviewWindow } from '@tauri-apps/api/window'
import { invoke } from '@tauri-apps/api/tauri'
import { EventEnum } from '@/enums'
export const useWindow = () => {
/**
@ -27,7 +28,7 @@ export const useWindow = () => {
) => {
const checkLabel = computed(() => {
/** 如果是打开独立窗口就截取label中的固定label名称 */
if (label.includes('alone')) {
if (label.includes(EventEnum.ALONE)) {
return label.replace(/\d/g, '')
} else {
return label

View File

@ -1,5 +1,10 @@
<template>
<main data-tauri-drag-region class="resizable select-none" :style="{ width: width + 'px' }">
<main
data-tauri-drag-region
id="center"
class="resizable select-none flex flex-col"
:style="{ width: `${initWidth}px` }">
<div class="resize-handle" @mousedown="initDrag"></div>
<ActionBar
class="absolute right-0"
v-if="shrinkStatus"
@ -47,19 +52,41 @@ import Mitt from '@/utils/Bus.ts'
import router from '@/router'
import { MittEnum } from '@/enums'
import { appWindow } from '@tauri-apps/api/window'
import { useWindowSize } from '@vueuse/core'
// const minWidth = 160 //
// const maxWidth = 320 //
const width = ref(250) //
/** 设置最小宽度 */
const minWidth = 160
/** 设置最大宽度 */
const maxWidth = 300
/** 初始化宽度 */
const initWidth = ref(250)
/**! 使用(vueUse函数获取)视口宽度 */
const { width } = useWindowSize()
/** 是否拖拽 */
const isDrag = ref(true)
/** 当前消息 */
const currentMsg = ref()
// const startX = ref()
// const startWidth = ref()
const startX = ref()
const startWidth = ref()
const shrinkStatus = ref(false)
// todo 1. 2.
Mitt.on(MittEnum.SHRINK_WINDOW, (event) => {
shrinkStatus.value = event as boolean
width.value = 250
watchEffect(() => {
if (width.value >= 310 && width.value < 800) {
Mitt.emit(MittEnum.SHRINK_WINDOW, true)
const center = document.querySelector('#center')
center?.classList.add('flex-1')
isDrag.value = false
}
if (width.value >= 800) {
Mitt.emit(MittEnum.SHRINK_WINDOW, false)
if (currentMsg.value) {
Mitt.emit(MittEnum.MSG_BOX_SHOW, { msgBoxShow: true, ...currentMsg.value })
}
const center = document.querySelector('#center')
center?.classList.remove('flex-1')
isDrag.value = true
}
})
const closeMenu = (event: Event) => {
@ -71,42 +98,51 @@ const closeMenu = (event: Event) => {
}
}
onMounted(() => {
/** 定义一个函数,在鼠标拖动时调用 */
const doDrag = (e: MouseEvent) => {
// 使 requestAnimationFrame
requestAnimationFrame(() => {
//
const newWidth = startWidth.value + e.clientX - startX.value
//
if (newWidth !== maxWidth) {
initWidth.value = clamp(newWidth, minWidth, maxWidth) // 使 clamp
}
})
}
/** 定义一个函数,用于将数值限制在指定的最小值和最大值之间 */
const clamp = (value: number, min: number, max: number) => {
return Math.min(Math.max(value, min), max) // 使 Math.min Math.max
}
const initDrag = (e: MouseEvent) => {
if (!isDrag.value) return
startX.value = e.clientX
startWidth.value = initWidth.value
document.addEventListener('mousemove', doDrag, false)
document.addEventListener('mouseup', stopDrag, false)
}
const stopDrag = () => {
document.removeEventListener('mousemove', doDrag, false)
document.removeEventListener('mouseup', stopDrag, false)
}
onMounted(async () => {
Mitt.on(MittEnum.SHRINK_WINDOW, (event) => {
shrinkStatus.value = event as boolean
})
Mitt.on(MittEnum.MSG_BOX_SHOW, (event: any) => {
if (!event) return
currentMsg.value = event
})
window.addEventListener('click', closeMenu, true)
})
onUnmounted(() => {
window.removeEventListener('click', closeMenu, true)
})
// watchEffect(() => {
// if (width.value === maxWidth) {
// Mitt.emit('shrinkWindow', false)
// }
// })
// const initDrag = (e: MouseEvent) => {
// startX.value = e.clientX
// startWidth.value = width.value
//
// document.addEventListener('mousemove', doDrag, false)
// document.addEventListener('mouseup', stopDrag, false)
// }
//
// const doDrag = (e: MouseEvent) => {
// const newWidth = startWidth.value + e.clientX - startX.value
// if (newWidth <= maxWidth && newWidth >= minWidth) {
// width.value = newWidth
// } else if (newWidth > maxWidth) {
// width.value = maxWidth
// } else if (newWidth < minWidth) {
// width.value = minWidth
// }
// }
//
// const stopDrag = () => {
// document.removeEventListener('mousemove', doDrag, false)
// document.removeEventListener('mouseup', stopDrag, false)
// }
</script>
<style scoped lang="scss">

View File

@ -10,7 +10,8 @@
right: 0;
top: 0;
bottom: 0;
width: 2px;
width: 1px;
cursor: ew-resize;
background-color: #ccc; /** 可以根据需要更改颜色 */
z-index: 9999;
background-color: var(--split-color);
}

View File

@ -13,7 +13,6 @@ import Right from './right/index.vue'
import Mitt from '@/utils/Bus'
import { MittEnum } from '@/enums'
/** todo home窗口创建的时候已经设置了resizable: true,可以调整大小了,但是还是不可以调整大小 */
const shrinkStatus = ref(false)
/**
* event默认如果没有传递值就为true所以shrinkStatus的值为false就会发生值的变化

View File

@ -3,6 +3,7 @@ import { emit } from '@tauri-apps/api/event'
import { EventEnum } from '@/enums'
import { delay } from 'lodash-es'
import { invoke } from '@tauri-apps/api/tauri'
import axios from 'axios'
const { createWebviewWindow } = useWindow()
const itemsTop = ref<OPT.L.Top[]>([
@ -75,6 +76,11 @@ const moreList = ref<OPT.L.MoreList[]>([
label: '退出账号',
icon: 'power',
click: async () => {
localStorage.removeItem('USER_INFO')
localStorage.removeItem('TOKEN')
// 清空axios请求头
const instance = axios.create()
instance.defaults.headers.common.Authorization = ''
// todo 退出账号 需要关闭其他的全部窗口
await createWebviewWindow('登录', 'login', 320, 448, 'home', true, false, 320, 448).then(() => {
/** 给一点延迟,不然创建登录窗口后还没有来得及设置阴影和圆角效果 */

View File

@ -1,50 +1,50 @@
<template>
<main data-tauri-drag-region class="left w-60px h-full p-[30px_6px_15px] box-border flex-col-center select-none">
<main class="left min-w-60px h-full p-[30px_6px_15px] box-border flex-col-center select-none" data-tauri-drag-region>
<!-- 点击时头像内容框 -->
<n-popover
v-model:show="infoShow"
trigger="click"
:show-arrow="false"
:placement="shrinkStatus ? 'bottom-start' : 'right-start'"
style="padding: 0; background: rgba(255, 255, 255, 0.2); backdrop-filter: blur(10px)">
:show-arrow="false"
style="padding: 0; background: rgba(255, 255, 255, 0.2); backdrop-filter: blur(10px)"
trigger="click">
<template #trigger>
<!-- 头像 -->
<div class="relative size-36px rounded-50% cursor-pointer">
<n-avatar round :color="'#fff'" :size="36" :src="login.accountInfo.avatar" fallback-src="/logo.png" />
<n-avatar :color="'#fff'" :size="36" :src="login.accountInfo.avatar" fallback-src="/logo.png" round />
<div
@click.stop="openContent('在线状态', 'onlineStatus', 320, 480)"
class="bg-[--bg-avatar] text-10px rounded-50% size-12px absolute bottom--2px right--2px border-(2px solid [--bg-avatar])">
<img class="rounded-50% size-full" :src="url" alt="" />
class="bg-[--bg-avatar] text-10px rounded-50% size-12px absolute bottom--2px right--2px border-(2px solid [--bg-avatar])"
@click.stop="openContent('在线状态', 'onlineStatus', 320, 480)">
<img :src="url" alt="" class="rounded-50% size-full" />
</div>
</div>
</template>
<!-- 用户个人信息框 -->
<n-flex
vertical
:size="26"
:style="`background: linear-gradient(to bottom, ${bgColor} 0%, ${themeColor} 100%)`"
class="size-full p-15px box-border rounded-8px"
:style="`background: linear-gradient(to bottom, ${bgColor} 0%, ${themeColor} 100%)`">
vertical>
<!-- 头像以及信息区域 -->
<n-flex justify="space-between" align="center" :size="25">
<n-flex :size="25" align="center" justify="space-between">
<n-flex>
<img class="size-68px rounded-50% select-none" :src="login.accountInfo.avatar" alt="" />
<img :src="login.accountInfo.avatar" alt="" class="size-68px rounded-50% select-none" />
<n-flex vertical justify="center" :size="10" class="text-[--text-color]">
<n-flex :size="10" class="text-[--text-color]" justify="center" vertical>
<span class="text-18px">{{ login.accountInfo.name }}</span>
<span class="text-(12px [--info-text-color])">账号 {{ login.accountInfo.uid }}</span>
<n-flex
@click="openContent('在线状态', 'onlineStatus', 320, 480)"
:size="5"
align="center"
class="item-hover ml--4px">
<img class="rounded-50% size-18px" :src="url" alt="" />
class="item-hover ml--4px"
@click="openContent('在线状态', 'onlineStatus', 320, 480)">
<img :src="url" alt="" class="rounded-50% size-18px" />
<span>{{ title }}</span>
</n-flex>
</n-flex>
</n-flex>
<n-flex vertical align="center" :size="5" class="item-hover">
<n-flex :size="5" align="center" class="item-hover" vertical>
<svg class="size-20px"><use href="#thumbs-up"></use></svg>
<span class="text-12px">9999+</span>
</n-flex>
@ -58,36 +58,36 @@
<n-flex :size="40" class="select-none">
<span class="text-[--info-text-color]">动态</span>
<n-image-group>
<n-flex :size="6" :class="shrinkStatus ? 'overflow-hidden w-180px' : ''" :wrap="false">
<n-flex :class="shrinkStatus ? 'overflow-hidden w-180px' : ''" :size="6" :wrap="false">
<n-image
v-for="n in 4"
:key="n"
preview-disabled
class="rounded-8px"
width="50"
src="https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg" />
preview-disabled
src="https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg"
width="50" />
</n-flex>
</n-image-group>
</n-flex>
<n-flex justify="center" align="center" :size="40">
<n-button secondary> 编辑资料 </n-button>
<n-flex :size="40" align="center" justify="center">
<n-button secondary @click="handleEditing"> 编辑资料 </n-button>
</n-flex>
</n-flex>
</n-popover>
<div data-tauri-drag-region class="flex-1 mt-20px flex-col-x-center justify-between">
<div class="flex-1 mt-20px flex-col-x-center justify-between" data-tauri-drag-region>
<!-- 上部分操作栏 -->
<header class="flex-col-x-center gap-10px color-[--icon-color]">
<div
v-for="(item, index) in itemsTop"
:key="index"
@click="pageJumps(item.url)"
:class="[
{ active: activeUrl === item.url && item.url !== 'dynamic' },
openWindowsList.has(item.url) ? 'p-[6px_8px] color-#13987f' : 'top-action'
]">
<n-badge :value="item.badge" :max="99">
]"
@click="pageJumps(item.url)">
<n-badge :max="99" :value="item.badge">
<svg class="size-22px">
<use
:href="`#${activeUrl === item.url || openWindowsList.has(item.url) ? item.iconAction : item.icon}`"></use>
@ -101,17 +101,17 @@
<div
v-for="(item, index) in itemsBottom"
:key="index"
@click="openContent(item.title, item.label)"
:class="openWindowsList.has(item.url.substring(1)) ? 'p-[6px_8px] color-#13987f' : 'bottom-action'">
:class="openWindowsList.has(item.url.substring(1)) ? 'p-[6px_8px] color-#13987f' : 'bottom-action'"
@click="openContent(item.title, item.label)">
<svg class="size-22px">
<use :href="`#${openWindowsList.has(item.url.substring(1)) ? item.iconAction : item.icon}`"></use>
</svg>
</div>
<svg
@click="settingShow = !settingShow"
:class="{ 'color-#13987f': settingShow }"
class="more size-22px relative"
:class="{ 'color-#13987f': settingShow }">
@click="settingShow = !settingShow">
<use :href="settingShow ? '#hamburger-button-action' : '#hamburger-button'"></use>
</svg>
@ -128,23 +128,121 @@
</div>
</footer>
</div>
<!-- 编辑资料弹窗 -->
<n-modal v-model:show="editInfo.show" :mask-closable="false" class="rounded-8px" transform-origin="center">
<div class="bg-[--bg-edit] w-480px h-fit box-border flex flex-col">
<n-flex :size="6" vertical>
<n-flex class="text-(14px --text-color) select-none pt-6px" justify="center">编辑资料</n-flex>
<svg
class="size-14px ml-a cursor-pointer pt-6px select-none absolute right-6px"
@click="editInfo.show = false">
<use href="#close"></use>
</svg>
<span class="h-1px w-full bg-[--line-color]"></span>
</n-flex>
<n-flex :size="20" class="p-22px select-none" vertical>
<!-- 头像 -->
<n-flex justify="center">
<n-avatar :size="80" :src="editInfo.content.avatar" round style="border: 3px solid #fff" />
</n-flex>
<n-flex v-if="currentBadge" align="center" justify="center">
<span class="text-(14px #707070)">当前佩戴的徽章:</span>
<n-popover trigger="hover">
<template #trigger>
<img :src="currentBadge?.img" alt="" class="size-22px" />
</template>
<span>{{ currentBadge?.describe }}</span>
</n-popover>
</n-flex>
<!-- 昵称编辑输入框 -->
<n-popover placement="top-start" trigger="click">
<template #trigger>
<n-input
ref="inputInstRef"
v-model:value="editInfo.content.name"
:count-graphemes="countGraphemes"
:default-value="editInfo.content.name"
:maxlength="8"
:passively-activated="true"
class="rounded-6px"
clearable
placeholder="请输入你的昵称"
show-count
type="text">
<template #prefix>
<span class="pr-6px text-#909090">昵称</span>
</template>
</n-input>
</template>
<span>剩余改名次数: {{ editInfo.content.modifyNameChance || 0 }}</span>
</n-popover>
<!-- 徽章列表 -->
<n-flex :size="[56, 20]" align="center">
<template v-for="item in editInfo.badgeList" :key="item.id">
<div class="badge-item">
<n-image
:class="{ 'grayscale-0': item.obtain === IsYetEnum.YES }"
:src="item.img"
alt="badge"
class="flex-center grayscale"
width="100"
height="100"
preview-disabled
round />
<div class="tip">
<template v-if="item.obtain === IsYetEnum.YES">
<n-button v-if="item.wearing === IsYetEnum.NO" color="#13987f" @click="toggleWarningBadge(item)">
佩戴
</n-button>
</template>
<n-popover trigger="hover">
<template #trigger>
<svg class="size-24px outline-none"><use href="#tips"></use></svg>
</template>
<span>{{ item.describe }}</span>
</n-popover>
</div>
</div>
</template>
</n-flex>
</n-flex>
<n-flex class="p-12px" align="center" justify="center">
<n-button :disabled="editInfo.content.name === login.accountInfo.name" color="#13987f" @click="saveEditInfo">
保存
</n-button>
</n-flex>
</div>
</n-modal>
</main>
</template>
<script setup lang="ts">
<script lang="ts" setup>
import { delay } from 'lodash-es'
import { useWindow } from '@/hooks/useWindow.ts'
import router from '@/router'
import Mitt from '@/utils/Bus.ts'
import { EventEnum, MittEnum, ThemeEnum } from '@/enums'
import { EventEnum, IsYetEnum, MittEnum, MsgEnum, ThemeEnum } from '@/enums'
import { listen } from '@tauri-apps/api/event'
import { itemsTop, itemsBottom, moreList } from './config.ts'
import { itemsBottom, itemsTop, moreList } from './config.ts'
import { onlineStatus } from '@/stores/onlineStatus.ts'
import { storeToRefs } from 'pinia'
import { setting } from '@/stores/setting.ts'
import apis from '@/services/apis.ts'
import GraphemeSplitter from 'grapheme-splitter'
import { BadgeType, UserInfoType } from '@/services/types.ts'
import { useUserInfo } from '@/hooks/useCached.ts'
import { renderReplyContent } from '@/utils/RenderReplyContent.ts'
import { formatTimestamp } from '@/utils/ComputedTime.ts'
import { useChatStore } from '@/stores/chat.ts'
import { useUserStore } from '@/stores/user.ts'
import { useCachedStore } from '@/stores/cached.ts'
const prefers = matchMedia('(prefers-color-scheme: dark)')
const { createWebviewWindow } = useWindow()
const settingStore = setting()
const userStore = useUserStore()
const cachedStore = useCachedStore()
const { themes, login } = storeToRefs(settingStore)
const OLStatusStore = onlineStatus()
const { url, title, bgColor } = storeToRefs(OLStatusStore)
@ -156,12 +254,53 @@ const infoShow = ref(false)
const themeColor = ref(themes.value.content === ThemeEnum.DARK ? 'rgba(63,63,63, 0.2)' : 'rgba(241,241,241, 0.2)')
/** 已打开窗口的列表 */
const openWindowsList = ref(new Set())
/** 编辑资料弹窗 */
// TODO (nyh -> 2024-05-05 01:12:36)
const editInfo = ref<{
show: boolean
content: Partial<UserInfoType>
badgeList: BadgeType[]
}>({
show: false,
content: {},
badgeList: []
})
/** 当前用户佩戴的徽章 */
const currentBadge = computed(() =>
editInfo.value.badgeList.find((item) => item.obtain === IsYetEnum.YES && item.wearing === IsYetEnum.YES)
)
/** 跟随系统主题模式切换主题 */
const followOS = () => {
themeColor.value = prefers.matches ? 'rgba(63,63,63, 0.2)' : 'rgba(241,241,241, 0.2)'
}
const chatStore = useChatStore()
const sessionList = computed(() =>
chatStore.sessionList.map((item) => {
//
const lastMsg = Array.from(chatStore.messageMap.get(item.roomId)?.values() || [])?.slice(-1)?.[0]
let LastUserMsg = ''
if (lastMsg) {
const lastMsgUserName = useUserInfo(lastMsg.fromUser.uid)
LastUserMsg =
lastMsg.message?.type === MsgEnum.RECALL
? `${lastMsgUserName.value.name}:'撤回了一条消息'`
: renderReplyContent(
lastMsgUserName.value.name,
lastMsg.message?.type,
lastMsg.message?.body?.content || lastMsg.message?.body
)!
}
return {
...item,
lastMsg: LastUserMsg || item.text || '欢迎使用HuLa',
lastMsgTime: formatTimestamp(item?.activeTime)
}
})
)
const msgTotal = computed(() => {
return sessionList.value.reduce((total, item) => total + item.unreadCount, 0)
})
watchEffect(() => {
Mitt.on(MittEnum.UPDATE_MSG_TOTAL, (event) => {
itemsTop.value.find((item) => {
@ -170,6 +309,11 @@ watchEffect(() => {
}
})
})
itemsTop.value.find((item) => {
if (item.url === 'message') {
item.badge = msgTotal.value
}
})
Mitt.on(MittEnum.TO_SEND_MSG, (event: any) => {
activeUrl.value = event.url
})
@ -182,6 +326,64 @@ watchEffect(() => {
}
})
/** 更新缓存里面的用户信息 */
const updateCurrentUserCache = (key: 'name' | 'wearingItemId', value: any) => {
const currentUser = userStore.userInfo.uid && cachedStore.userCachedList[userStore.userInfo.uid]
if (currentUser) {
currentUser[key] = value //
}
}
/** 保存用户信息 */
const saveEditInfo = () => {
if (!editInfo.value.content.name || editInfo.value.content.name.trim() === '') {
window.$message.error('昵称不能为空')
return
}
if (editInfo.value.content.modifyNameChance === 0) {
window.$message.error('改名次数不足')
return
}
apis.modifyUserName(editInfo.value.content.name).then((res) => {
if (!res.success) {
window.$message.error(res.errMsg)
return
}
//
login.value.accountInfo.name = editInfo.value.content.name!
updateCurrentUserCache('name', editInfo.value.content.name) //
if (!editInfo.value.content.modifyNameChance) return
editInfo.value.content.modifyNameChance -= 1
window.$message.success('保存成功')
})
}
/** 佩戴卸下徽章 */
const toggleWarningBadge = async (badge: BadgeType) => {
if (!badge?.id) return
await apis.setUserBadge(badge.id)
}
/** 计算字符长度 */
const countGraphemes = (value: string) => {
const splitter = new GraphemeSplitter()
return splitter.countGraphemes(value)
}
/* 打开并且创建modal */
const handleEditing = () => {
/** 获取用户的徽章列表 */
apis.getBadgeList().then((res) => {
editInfo.value.badgeList = res.data
})
/** 获取用户信息 */
apis.getUserInfo().then((res) => {
editInfo.value.content = res.data
})
infoShow.value = false
editInfo.value.show = true
}
/**
* 统一跳转路由方法
* @param url 跳转的路由
@ -241,6 +443,16 @@ onUnmounted(() => {
})
</script>
<style scoped lang="scss">
<style lang="scss" scoped>
@import 'style';
.badge-item {
.tip {
transition: opacity 0.4s ease-in-out;
@apply absolute top-0 left-0 w-full h-full flex-center z-999 opacity-0;
}
@apply bg-#ccc relative rounded-50% size-fit p-4px cursor-pointer;
&:hover .tip {
@apply opacity-100;
}
}
</style>

View File

@ -1,5 +1,5 @@
<template>
<main class="flex-1 bg-[--right-bg-color] h-full w-100vw">
<main class="flex-1 bg-[--right-bg-color] h-full w-100vw min-w-600px">
<ActionBar :current-label="appWindow.label" />
<!-- 需要判断当前路由是否是信息详情界面 -->
<ChatBox :active-item="activeItem" v-if="msgBoxShow && isChat && activeItem !== -1" />
@ -67,7 +67,7 @@ onMounted(() => {
if (isDetails) {
Mitt.on(MittEnum.DETAILS_SHOW, (event: any) => {
DetailsContent.value = event.data
DetailsContent.value = event.context
detailsShow.value = event.detailsShow as boolean
})
}

View File

@ -1,6 +1,7 @@
import { createApp } from 'vue'
import 'uno.css'
import '@unocss/reset/eric-meyer.css' // unocss提供的浏览器默认样式重置
import '@/services/webSocket'
import { pinia } from '@/stores'
import vResize from '@/directives/v-resize'
import vSlide from '@/directives/v-slide.ts'
@ -9,3 +10,9 @@ import App from '@/App.vue'
const app = createApp(App)
app.use(router).use(pinia).directive('resize', vResize).directive('slide', vSlide).mount('#app')
if (process.env.NODE_ENV === 'development') {
import('@/utils/console.ts').then((module) => {
/**! 控制台打印项目版本信息(不需要可手动关闭)*/
module.consolePrint()
})
}

View File

@ -25,7 +25,7 @@ export const MockList = ref<MockItem[]>(
key: i,
avatar: `${avatars}?${i}`,
type: type,
accountId: `${i}`,
accountId: i,
accountName: generateRandomString(Math.floor(Math.random() * 10) + 1, type)
}
})

View File

@ -1,20 +1,122 @@
import { createAxios } from '@/services/request'
import urls from '@/services/urls'
import type { login, Response } from '@/services/types'
import { parameter } from '@/services/types'
import type {
Response,
BadgeType,
CacheBadgeItem,
CacheBadgeReq,
CacheUserItem,
CacheUserReq,
ContactItem,
EmojiItem,
GroupDetailReq,
GroupStatisticType,
ListResponse,
MarkMsgReq,
MessageReq,
MessageType,
MsgReadUnReadCountType,
RequestFriendItem,
SessionItem,
UserInfoType,
UserItem
} from '@/services/types'
const request = createAxios()
const GET = <T>(url: string, params?: any) => request.get<T, Response>(url, params)
const POST = <T>(url: string, params?: any) => request.post<T, Response>(url, params)
// const PUT = <T>(url: string, params?: any) => request.put<T, Response>(url, params)
// const DELETE = <T>(url: string, params?: any) => request.delete<T, Response>(url, params)
const PUT = <T>(url: string, params?: any) => request.put<T, Response>(url, params)
const DELETE = <T>(url: string, params?: any) => request.delete<T, Response>(url, params)
export default {
/**登录 请求*/
login: (form: login): Promise<Response> => POST(urls.login, form),
/**退出 请求*/
logout: (): Promise<Response> => GET(urls.logout),
/**获取文章列表*/
getArticlePage: (params: parameter): Promise<Response> => GET(urls.articlePage, { params })
/** 获取用户信息 */
getUserInfo: (): Promise<Response> => GET(urls.getUserInfo),
/** 获取群成员列表 */
getGroupList: (params?: any) => GET<ListResponse<UserItem>>(urls.getGroupUserList, params),
/** 获取群成员统计 */
getMemberStatistic: () => GET<GroupStatisticType>(urls.getMemberStatistic),
/** 房间内的所有群成员列表-@专用 */
getAllUserBaseInfo: (params?: any) =>
GET<Pick<CacheUserItem, 'avatar' | 'name' | 'uid'>[]>(urls.getAllUserBaseInfo, params),
/** 批量获取成员详细信息 */
getUserInfoBatch: (users: CacheUserReq[]) => POST<CacheUserItem[]>(urls.getUserInfoBatch, { reqList: users }),
/** 批量获取徽章信息 */
getBadgesBatch: (badges: CacheBadgeReq[]) => POST<CacheBadgeItem[]>(urls.getBadgesBatch, { reqList: badges }),
/** 获取消息列表 */
getMsgList: (params?: any) => GET<ListResponse<MessageType>>(urls.getMsgList, params),
/** 发送消息 */
sendMsg: (data?: MessageReq) => POST<MessageType>(urls.sendMsg, data),
/** 标记消息,点赞等 */
markMsg: (data?: MarkMsgReq) => PUT<void>(urls.markMsg, data),
/** 获取用户详细信息 */
getUserDetail: () => GET<UserInfoType>(urls.getUserInfoDetail, {}),
/** 获取徽章列表 */
getBadgeList: (): Promise<Response> => GET<BadgeType[]>(urls.getBadgeList),
/** 设置用户勋章 */
setUserBadge: (badgeId: number) => PUT<void>(urls.setUserBadge, { badgeId }),
/** 修改用户名 */
modifyUserName: (name: string) => PUT<void>(urls.modifyUserName, { name }),
/** 撤回消息 */
recallMsg: (data: { msgId: number; roomId: number }) => PUT<void>(urls.recallMsg, data),
/** 拉黑用户 */
blockUser: (data: { uid: number }) => PUT<void>(urls.blockUser, data),
/** 获取临时上传链接 */
getUploadUrl: (params: any) => GET<{ downloadUrl: string; uploadUrl: string }>(urls.fileUpload, { params }),
/** 新增表情包 */
addEmoji: (data: { uid: number; expressionUrl: string }) => POST<MessageType>(urls.addEmoji, data),
/** 获取表情 **/
getEmoji: (params: { uid: number }) => GET<EmojiItem[]>(urls.getEmoji, { params }),
/** 删除id */
deleteEmoji: (params: { id: number }) => DELETE<EmojiItem[]>(urls.deleteEmoji, params),
/** 获取联系人列表 */
getContactList: (params?: any) => GET<ListResponse<ContactItem>>(urls.getContactList, { params }),
/** 获取好友申请列表 */
requestFriendList: (params?: any) => GET<ListResponse<RequestFriendItem>>(urls.requestFriendList, { params }),
/** 发送添加好友请求 */
sendAddFriendRequest: (params: { targetUid: number; msg: string }) =>
POST<EmojiItem[]>(urls.sendAddFriendRequest, params),
/** 同意好友申请 */
applyFriendRequest: (params: { applyId: number }) => PUT(urls.sendAddFriendRequest, params),
/** 同意好友申请 */
deleteFriend: (params: { targetUid: number }) => DELETE(urls.deleteFriend, params),
/** 好友申请未读数 */
newFriendCount: () => GET<{ unReadCount: number }>(urls.newFriendCount),
/** 会话列表 */
getSessionList: (params?: any) => GET<ListResponse<SessionItem>>(urls.getSessionList, params),
/** 消息的已读未读列表 */
getMsgReadList: (params?: any) => GET<ListResponse<{ uid: number }>>(urls.getMsgReadList, params),
/** 消息已读未读数 */
getMsgReadCount: (params?: any) => GET<MsgReadUnReadCountType[]>(urls.getMsgReadCount, params),
/** 消息阅读上报 */
markMsgRead: (params?: any) => PUT<MsgReadUnReadCountType[]>(urls.getMsgReadCount, params),
/** 新增群组 */
createGroup: (params: { uidList: number[] }) => POST<{ id: number }>(urls.createGroup, params),
/** 邀请群成员 */
inviteGroupMember: (params: { roomId: number; uidList: number[] }) => POST(urls.inviteGroupMember, params),
/** 删除群成员 */
removeGroupMember: (params: { roomId: number; uid: number }) => DELETE(urls.inviteGroupMember, params),
/** 群组详情 */
groupDetail: (params: { id: number }) => GET<GroupDetailReq>(urls.groupDetail, { params }),
/** 会话详情 */
sessionDetail: (params: { id: number }) => GET<SessionItem>(urls.sessionDetail, { params }),
/** 会话详情(联系人列表发消息用) */
sessionDetailWithFriends: (params: { uid: number }) => GET<SessionItem>(urls.sessionDetailWithFriends, { params }),
/** 添加群管理 */
addAdmin: ({ roomId, uidList }: { roomId: number; uidList: number[] }) =>
PUT<boolean>(urls.addAdmin, {
roomId,
uidList
}),
/** 撤销群管理 */
revokeAdmin: ({ roomId, uidList }: { roomId: number; uidList: number[] }) =>
DELETE<boolean>(urls.revokeAdmin, {
roomId,
uidList
}),
/** 退群 */
exitGroup: ({ roomId }: { roomId: number }) =>
DELETE<boolean>(urls.exitGroup, {
roomId
})
}

View File

@ -1,5 +1,29 @@
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'
import { userStore } from '@/stores/user.ts'
import { setting } from '@/stores/setting.ts'
/** 是否是测试环境 */
const isTest = computed(() => {
return setting().login.accountInfo.token === 'test'
})
function getToken() {
let tempToken = ''
return {
get() {
if (tempToken) return tempToken
const token = localStorage.getItem('TOKEN')
if (token) {
tempToken = token
}
return tempToken
},
clear() {
tempToken = ''
}
}
}
export const computedToken = getToken()
//请求配置
export const createAxios = (config?: AxiosRequestConfig): AxiosInstance => {
@ -19,15 +43,25 @@ export const createAxios = (config?: AxiosRequestConfig): AxiosInstance => {
instance.interceptors.request.use(
function (config: any) {
//判断是否有token 根据自己的需求判断
const token = userStore().getBearerToken
if (token != undefined) {
//如果要求携带在参数中
const token = setting().login.accountInfo.token
if (isTest.value) {
// 如果token为'test',阻止请求并返回一个错误对象
return Promise.reject(
window.$message.create('当前为测试环境,请注意辨别', {
type: 'warning',
closable: true,
duration: 0
})
)
}
if (token != void 0) {
// //如果要求携带在参数中
// config.params = Object.assign({}, config.params, token)
// 如果要求携带在请求头中
// config.headers = Object.assign({}, config.headers, operate.uploadParameters())
config.headers['Content-Type'] = 'application/json;charset=utf-8'
// 设置请求头
config.headers['token'] = token
config.headers['Authorization'] = `Bearer ${token}`
}
return config
},
@ -98,14 +132,8 @@ export const createAxios = (config?: AxiosRequestConfig): AxiosInstance => {
default:
error.message = `连接错误${error.response.status}`
}
} else {
// 超时处理
if (JSON.stringify(error).includes('timeout')) {
error.message = '服务器响应超时,请刷新当前页'
} else {
error.message = '连接服务器失败'
}
}
if (isTest) return Promise.resolve(error.response)
window.$message.error(error.message)
/***** 处理结束 *****/
return Promise.resolve(error.response)

View File

@ -3,50 +3,233 @@
* 使TSDoc规范进行注释便使
* @see TSDoc规范https://tsdoc.org/
**/
import { MsgEnum, RCodeEnum, RoomTypeEnum } from '@/enums'
import { ActEnum, IsYetEnum, MarkEnum, MsgEnum, OnlineEnum, RoomTypeEnum, SexEnum } from '@/enums'
/**响应请求体*/
export type Response = {
code: RCodeEnum
msg: string
data: {
records: any
total: number
}
fail: boolean
/** 成功标识true or false */
success: boolean
version: string
/** 错误码 */
errCode: number
/** 错误消息 */
errMsg: string
/** 数据 */
data: any
}
/**分页搜索*/
export type parameter = {
pageNum: number
pageSize: number
/* ======================================================== */
export type ListResponse<T> = {
/** 游标(下次翻页带上这参数)*/
cursor: string
/** 是否最后一页 */
isLast: boolean
list: T[]
}
export type CacheBadgeReq = {
/** 最后更新时间 更新超过 10 分钟异步去更新。 */
lastModifyTime?: number
/** 徽章 ID */
itemId: number
}
export type GroupDetailReq = {
/** 群头像 */
avatar: string
/** 群名称 */
groupName: string
/** 在线人数 */
onlineNum: number
/** 成员角色 1群主 2管理员 3普通成员 4踢出群聊 */
role: number
/** 房间id */
roomId: number
}
export type CacheBadgeItem = {
/** 是否需要更新数据源。 */
needRefresh?: boolean
/** 最后更新时间 更新超过 10 分钟异步去更新。 */
lastModifyTime: number
/** 徽章说明 */
describe: string
/** 徽章图标 */
img: string
/** 徽章 ID */
itemId: number
}
export type CacheUserReq = {
/** 最后更新时间 更新超过 10 分钟异步去更新。 */
lastModifyTime?: number
/** uid */
uid: number
}
export type CacheUserItem = {
/** 是否需要更新数据源。 */
needRefresh?: boolean
/** 最后更新时间 更新超过 10 分钟异步去更新。 */
lastModifyTime: number
/** 获得的徽章 */
itemIds: number[]
/** 佩戴的徽章 */
wearingItemId: number
/** 归属地 */
locPlace: string
/** 头像 */
avatar: string
/** 最后一次上下线时间 */
lastOptTime: number
/** 用户名称 */
name: string
}
/**登录类型*/
export type login = {
username: string
password: string
/** uid */
uid: number
}
/** ===================================================== */
/** 回复类型 */
export type ReplyType = {
id: number
username: string
type: MsgEnum
/** 根据不同类型回复的消息展示也不同-`过渡版` */
body: any
/**
*
* @enum {number} `0` `1`
*/
export type UserItem = {
/** 在线状态 */
activeStatus: OnlineEnum
/** 头像 */
avatar: string
/** 最后一次上下线时间 */
lastOptTime: number
/** 用户名称 */
name: string
/** 角色ID */
roleId?: number
/** uid */
uid: number
}
export type GroupStatisticType = {
/** 在线人数 */
onlineNum: number
/** 总人数 */
totalNum: number
}
export type MessageReplyType = {
/** 是否可消息跳转 0否 1是 */
canCallback: number
/** 跳转间隔的消息条数 */
/** 是否可消息跳转 0否 1是 */
content: string
/** 跳转间隔的消息条数 */
gapCount: number
/** 消息id */
id: number
/** 用户名称 */
username: string
}
/** 消息互动信息 */
export type MarkMsgReq = {
// actType 动作类型 1确认 2取消
actType: ActEnum
// 标记类型 1点赞 2举报
markType: MarkEnum
// 消息 ID
msgId: number
}
export type UserInfoType = {
/** 用户唯一标识 */
uid: number
/** 用户头像 */
avatar: string
/** 用户名 */
name: string
/** 剩余改名次数 */
modifyNameChance: number
/** 性别 1为男性2为女性 */
sex: SexEnum
/** 徽章,本地字段,有值用本地,无值用远端 */
badge?: string
/** 权限 */
power?: number
}
export type BadgeType = {
// 徽章描述
describe: string
// 徽章id
id: number
// 徽章图标
img: string
// 是否拥有 0否 1是
obtain: IsYetEnum
// 是否佩戴 0否 1是
wearing: IsYetEnum
}
export type MarkItemType = {
/** 操作用户 */
uid: number
/** 消息id */
msgId: number
/** 操作类型 1点赞 2举报 */
markType: MarkEnum
/** 数量 */
markCount: number
/** 动作类型 1确认 2取消 */
actType: ActEnum
}
export type RevokedMsgType = {
/** 消息ID */
msgId: number
/** 会话ID */
roomId?: number
/** 撤回人ID */
recallUid?: number
}
export type EmojiItem = {
expressionUrl: string
id: number
}
// -------------------- ⬇消息体类型定义⬇ ----------------
/**
*
*/
export type MessageType = {
/** 发送者信息 */
fromUser: MsgUserType
/** 消息主体 */
message: MsgType
/** 发送时间 */
sendTime: string
/** 时间段(可选) */
timeBlock?: string
/** 是否加载中 */
loading?: boolean
}
/**
*
*/
export type MsgUserType = {
/** 用户ID */
uid: number
/** 用户名 */
username: string
/** 头像 */
avatar: string
/** 归属地 */
locPlace: string
/** 徽章 */
badge?: {
/** 徽章地址 */
img: string
/** 描述 */
describe: string // 描述
}
}
/**
*
*/
export type MessageMarkType = {
/** 点赞 */
userLike: number
@ -109,7 +292,9 @@ export type EmojiBody = {
url: string
}
/** 消息内容 */
/**
*
*/
export type MsgType = {
/** 消息ID */
id: number
@ -125,32 +310,115 @@ export type MsgType = {
messageMark: MessageMarkType
}
/** 缓存用户项 */
export type CacheUserItem = {
/** 是否需要更新数据源。 */
needRefresh?: boolean
/** 最后更新时间 更新超过 10 分钟异步去更新。 */
lastModifyTime: number
/** 获得的徽章 */
itemIds: number[]
/** 佩戴的徽章 */
wearingItemId: number
/** 归属地 */
locPlace: string
/** 头像 */
avatar: string
export type ReplyType = {
id: number
username: string
type: MsgEnum
/** 根据不同类型回复的消息展示也不同-`过渡版` */
body: any
/**
*
* @enum {number} `0` `1`
*/
canCallback: number
/** 跳转间隔的消息条数 */
gapCount: number
}
/**
*
*/
export type MessageReq = {
/** 会话id */
roomId: number
/** 消息类型 */
msgType: MsgEnum
/** 消息体 */
body: {
/** 文本消息内容 */
content?: string
/** 回复的消息id */
replyMsgId?: number
/** 任意 */
[key: string]: any
}
}
/** 申请状态 */
export enum RequestFriendAgreeStatus {
/** 1待审批 */
Waiting = 1,
/** 2同意 */
Agree
}
/** 请求添加好友的列表项 */
export type RequestFriendItem = {
/** 申请id */
applyId: number
/** 申请信息 */
msg: string
/** 申请状态 1待审批 2同意 */
status: RequestFriendAgreeStatus
/** 申请类型 1加好友 */
type: number
/** 申请人uid */
uid: number
/** 会话 ID */
roomId: number
}
/** 联系人的列表项 */
export type ContactItem = {
/** 在线状态 1在线 2离线 */
activeStatus: OnlineEnum
/** 最后一次上下线时间 */
lastOptTime: number
/** 用户名称 */
name: string
/** uid */
uid: number
}
/** 是否全员展示的会话 0否 1是 */
export enum IsAllUserEnum {
/** 0否 */
Not,
/** 1是 */
Yes
}
/** 会话列表项 */
export type SessionItem = {
/** 房间最后活跃时间(用来排序) */
activeTime: number
/** 会话头像 */
avatar: string
/** 是否全员展示的会话 0否 1是 */
hot_Flag: IsAllUserEnum
/** 会话名称 */
name: string
/** 房间id */
roomId: number
/** 最新消息 */
text: string
/** 房间类型 1群聊 2单聊 */
type: RoomTypeEnum
/** 未读数 */
unreadCount: number
}
/** 消息已读未读数列表项 */
export type MsgReadUnReadCountType = {
/** 消息 ID */
msgId: number
/** 已读数 */
readCount: number
/** 未读数 */
unReadCount: number | null
}
/* ======================================================== */
/**! 模拟信息数据的类型 */
export type MockItem = {
key: number
type: RoomTypeEnum
avatar: string
accountId: string
accountId: number
accountName: string
}

View File

@ -5,7 +5,43 @@ const { PROD, VITE_SERVICE_URL } = import.meta.env
const prefix = PROD ? VITE_SERVICE_URL : ''
export default {
login: `${prefix + URLEnum.USER}/login`,
logout: `${prefix + URLEnum.USER}/logout`,
articlePage: `${prefix + URLEnum.ARTICLE}/page`
getUserInfo: `${prefix + URLEnum.USER}/userInfo`, // 获取用户信息
getBadgeList: `${prefix + URLEnum.USER}/badges`, // 获取徽章列表
getMemberStatistic: `${prefix + URLEnum.CHAT}/public/member/statistic`,
getUserInfoBatch: `${prefix + URLEnum.USER}/public/summary/userInfo/batch`,
getBadgesBatch: `${prefix + URLEnum.USER}/public/badges/batch`,
getAllUserBaseInfo: `${prefix + URLEnum.ROOM}/group/member/list`, // 房间内的所有群成员列表-@专用
getMsgList: `${prefix + URLEnum.CHAT}/public/msg/page`,
sendMsg: `${prefix + URLEnum.CHAT}/msg`,
getUserInfoDetail: `${prefix + URLEnum.USER}/userInfo`, // 获取用户信息详情
modifyUserName: `${prefix + URLEnum.USER}/name`, // 修改用户名
setUserBadge: `${prefix + URLEnum.USER}/badge`, // 设置用户徽章
markMsg: `${prefix + URLEnum.CHAT}/msg/mark`, // 消息标记
blockUser: `${prefix + URLEnum.USER}/black`, // 拉黑用户
recallMsg: `${prefix + URLEnum.CHAT}/msg/recall`, // 撤回消息
fileUpload: `${prefix + URLEnum.OSS}/upload/url`, // 文件上传
addEmoji: `${prefix + URLEnum.USER}/emoji`, // 增加表情
deleteEmoji: `${prefix + URLEnum.USER}/emoji`, // 删除表情
getEmoji: `${prefix + URLEnum.USER}/emoji/list`, // 查询表情包
// -------------- 好友相关 ---------------
getContactList: `${prefix + URLEnum.USER}/friend/page`, // 联系人列表
requestFriendList: `${prefix + URLEnum.USER}/friend/apply/page`, // 好友申请列表
sendAddFriendRequest: `${prefix + URLEnum.USER}/friend/apply`, // 申请好友
deleteFriend: `${prefix + URLEnum.USER}/friend`, // 删除好友
newFriendCount: `${prefix + URLEnum.USER}/friend/apply/unread`, // 申请未读数
// -------------- 聊天室相关 ---------------
getSessionList: `${prefix + URLEnum.CHAT}/public/contact/page`, // 会话列表
getMsgReadList: `${prefix + URLEnum.CHAT}/msg/read/page`, // 消息的已读未读列表
getMsgReadCount: `${prefix + URLEnum.CHAT}/msg/read`, // 消息已读未读数
createGroup: `${prefix + URLEnum.ROOM}/group`, // 新增群组
getGroupUserList: `${prefix + URLEnum.ROOM}/public/group/member/page`,
inviteGroupMember: `${prefix + URLEnum.ROOM}/group/member`, // 邀请群成员
exitGroup: `${prefix + URLEnum.ROOM}/group/member/exit`, // 退群
addAdmin: `${prefix + URLEnum.ROOM}/group/admin`, // 添加管理员
revokeAdmin: `${prefix + URLEnum.ROOM}/group/admin`, // 添加管理员
groupDetail: `${prefix + URLEnum.ROOM}/public/group`, // 群组详情
sessionDetail: `${prefix + URLEnum.CHAT}/public/contact/detail`, // 会话详情
sessionDetailWithFriends: `${prefix + URLEnum.CHAT}/public/contact/detail/friend` // 会话详情(联系人列表发消息用)
}

View File

@ -1,51 +1,246 @@
import { WsReqEnum, WsResEnum } from '@/enums'
import { useWsLoginStore, LoginStatus } from '@/stores/ws'
import { useUserStore } from '@/stores/user'
import { useChatStore } from '@/stores/chat'
import { useGroupStore } from '@/stores/group'
import { useGlobalStore } from '@/stores/global'
import { useEmojiStore } from '@/stores/emoji'
import { WsResponseMessageType } from '@/utils/wsType'
import type { LoginSuccessResType, LoginInitResType, WsReqMsgContentType, OnStatusChangeType } from '@/utils/wsType'
import type { MessageType, MarkItemType, RevokedMsgType } from '@/services/types'
import { OnlineEnum, ChangeTypeEnum, RoomTypeEnum, WsResEnum, MittEnum } from '@/enums'
import { computedToken } from '@/services/request'
import { worker } from '@/utils/InitWorker.ts'
import Mitt from '@/utils/Bus.ts'
const { VITE_WEBSOCKET_URL } = import.meta.env
/** websocket连接对象 */
let ws: WebSocket
/** 初始化websocket连接 */
const initWebSocket = () => {
ws = new WebSocket(`${VITE_WEBSOCKET_URL}/`)
ws.onopen = () => {
// 发送心跳
setInterval(() => {
sendToServer({
type: WsReqEnum.HEARTBEAT
})
}, 1000 * 60)
class WS {
#tasks: WsReqMsgContentType[] = []
// 重连🔐
#connectReady = false
constructor() {
this.initConnect()
// 收到消息
worker.addEventListener('message', this.onWorkerMsg)
// 后台重试次数达到上限之后tab 获取焦点再重试
document.addEventListener('visibilitychange', () => {
if (!document.hidden && !this.#connectReady) {
this.initConnect()
}
})
}
// 监听服务器返回的消息
ws.onmessage = (event: MessageEvent) => {
const data: Record<string, any> = JSON.parse(event.data)
switch (data.type) {
case WsReqEnum.LOGIN:
Mitt.emit(WsResEnum.QRCODE_LOGIN, data)
initConnect = () => {
const token = localStorage.getItem('TOKEN')
// 如果token 是 null, 而且 localStorage 的用户信息有值,需要清空用户信息
if (token === null && localStorage.getItem('USER_INFO')) {
localStorage.removeItem('USER_INFO')
}
// 初始化 ws
worker.postMessage(`{"type":"initWS","value":${token ? `"${token}"` : null}}`)
}
onWorkerMsg = (e: MessageEvent<any>) => {
const params: { type: string; value: unknown } = JSON.parse(e.data)
switch (params.type) {
case 'message': {
this.onMessage(params.value as string)
break
case WsReqEnum.HEARTBEAT:
}
case 'open': {
this.#dealTasks()
break
case WsReqEnum.AUTHORIZE:
Mitt.emit(WsResEnum.LOGIN_SUCCESS, data)
}
case 'close':
case 'error': {
this.#onClose()
break
}
case WsResEnum.WS_ERROR:
Mitt.emit(WsResEnum.WS_ERROR, params.value)
localStorage.removeItem('wsLogin')
break
}
}
// websocket出错重连
ws.onerror = () => {
// websocket出错重连
initWebSocket()
// 重置一些属性
#onClose = () => {
this.#connectReady = false
}
#dealTasks = () => {
this.#connectReady = true
// 先探测登录态
// this.#detectionLoginStatus()
setTimeout(() => {
const userStore = useUserStore()
if (userStore.isSign) {
// 处理堆积的任务
this.#tasks.forEach((task) => {
this.send(task)
})
// 清空缓存的消息
this.#tasks = []
}
}, 500)
}
#send(msg: WsReqMsgContentType) {
worker.postMessage(`{"type":"message","value":${typeof msg === 'string' ? msg : JSON.stringify(msg)}}`)
}
send = (params: WsReqMsgContentType) => {
if (this.#connectReady) {
this.#send(params)
} else {
// 放到队列
this.#tasks.push(params)
}
}
// 收到消息回调
onMessage = (value: string) => {
// FIXME 可能需要 try catch,
const params: { type: WsResponseMessageType; data: unknown } = JSON.parse(value)
const loginStore = useWsLoginStore()
const userStore = useUserStore()
const chatStore = useChatStore()
const groupStore = useGroupStore()
const globalStore = useGlobalStore()
const emojiStore = useEmojiStore()
switch (params.type) {
// 获取登录二维码
case WsResponseMessageType.LoginQrCode: {
const data = params.data as LoginInitResType
loginStore.loginQrCode = data.loginUrl
Mitt.emit(WsResEnum.QRCODE_LOGIN)
break
}
// 等待授权
case WsResponseMessageType.WaitingAuthorize: {
loginStore.loginStatus = LoginStatus.Waiting
break
}
// 登录成功
case WsResponseMessageType.LoginSuccess: {
userStore.isSign = true
const { token, ...rest } = params.data as LoginSuccessResType
Mitt.emit(WsResEnum.LOGIN_SUCCESS, params.data)
// FIXME 可以不需要赋值了,单独请求了接口。
userStore.userInfo = { ...userStore.userInfo, ...rest }
localStorage.setItem('USER_INFO', JSON.stringify(rest))
localStorage.setItem('TOKEN', token)
localStorage.removeItem('wsLogin')
// 更新一下请求里面的 token.
computedToken.clear()
computedToken.get()
// 获取用户详情
userStore.getUserDetailAction()
// 自己更新自己上线
groupStore.batchUpdateUserStatus([
{
activeStatus: OnlineEnum.ONLINE,
avatar: rest.avatar,
lastOptTime: Date.now(),
name: rest.name,
uid: rest.uid
}
])
// 获取用户详情
chatStore.getSessionList(true)
// 自定义表情列表
emojiStore.getEmojiList()
break
}
// 收到消息
case WsResponseMessageType.ReceiveMessage: {
chatStore.pushMsg(params.data as MessageType)
Mitt.emit(MittEnum.SEND_MESSAGE, params.data)
break
}
// 用户下线
case WsResponseMessageType.OnOffLine: {
const data = params.data as OnStatusChangeType
groupStore.countInfo.onlineNum = data.onlineNum
// groupStore.countInfo.totalNum = data.totalNum
groupStore.batchUpdateUserStatus(data.changeList)
break
}
// 用户 token 过期
case WsResponseMessageType.TokenExpired: {
userStore.isSign = false
userStore.userInfo = {}
localStorage.removeItem('USER_INFO')
localStorage.removeItem('TOKEN')
loginStore.loginStatus = LoginStatus.Init
break
}
// 小黑子的发言在禁用后,要删除他的发言
case WsResponseMessageType.InValidUser: {
const data = params.data as { uid: number }
// 消息列表删掉小黑子发言
chatStore.filterUser(data.uid)
// 群成员列表删掉小黑子
groupStore.filterUser(data.uid)
break
}
// 点赞、倒赞消息通知
case WsResponseMessageType.WSMsgMarkItem: {
const data = params.data as { markList: MarkItemType[] }
chatStore.updateMarkCount(data.markList)
break
}
// 消息撤回通知
case WsResponseMessageType.WSMsgRecall: {
const { data } = params as { data: RevokedMsgType }
chatStore.updateRecallStatus(data)
break
}
// 新好友申请
case WsResponseMessageType.RequestNewFriend: {
const data = params.data as { uid: number; unreadCount: number }
globalStore.unReadMark.newFriendUnreadCount += data.unreadCount
// notify({
// name: '新好友',
// text: '您有一个新好友, 快来看看~',
// onClick: () => {
// Router.push('/contact')
// }
// })
break
}
// 新好友申请
case WsResponseMessageType.NewFriendSession: {
// changeType 1 加入群组2 移除群组
const data = params.data as {
roomId: number
uid: number
changeType: ChangeTypeEnum
activeStatus: OnlineEnum
lastOptTime: number
}
if (
data.roomId === globalStore.currentSession.roomId &&
globalStore.currentSession.type === RoomTypeEnum.GROUP
) {
if (data.changeType === ChangeTypeEnum.REMOVE) {
// 移除群成员
groupStore.filterUser(data.uid)
// TODO 添加一条退出群聊的消息
} else {
// TODO 添加群成员
// TODO 添加一条入群的消息
}
}
break
}
default: {
console.log('接收到未处理类型的消息:', params)
break
}
}
}
}
/**
* json数据至服务器
*
* @param data json数据对象
*/
const sendToServer = (data: Record<string, any>) => {
const json = JSON.stringify(data)
ws.send(json)
}
export { initWebSocket, sendToServer }
export default new WS()

127
src/stores/cached.ts Normal file
View File

@ -0,0 +1,127 @@
import { defineStore } from 'pinia'
import apis from '@/services/apis'
import { useGlobalStore } from '@/stores/global'
import type { CacheBadgeItem, CacheUserItem } from '@/services/types'
import { isDiffNow10Min } from '@/utils/ComputedTime.ts'
export type BaseUserItem = Pick<CacheUserItem, 'uid' | 'avatar' | 'name'>
export const useCachedStore = defineStore('cached', () => {
const globalStore = useGlobalStore()
const userCachedList = reactive<Record<number, Partial<CacheUserItem>>>({})
const badgeCachedList = reactive<Record<number, Partial<CacheBadgeItem>>>({})
const currentRoomId = computed(() => globalStore.currentSession?.roomId)
const atUsersMap = reactive<Record<number, BaseUserItem[]>>({ [currentRoomId.value]: [] }) // 消息Map
const currentAtUsersList = computed({
get: () => {
const current = atUsersMap[currentRoomId.value]
if (current === undefined) {
atUsersMap[currentRoomId.value] = []
}
if (currentRoomId.value === 1) {
return Object.values(userCachedList as BaseUserItem[])
}
return atUsersMap[currentRoomId.value]
},
set: (val) => {
atUsersMap[currentRoomId.value] = val
}
})
/** 批量获取用户详细信息 */
const getBatchUserInfo = async (uids: number[]) => {
// 没有 lastModifyTime 的要更新lastModifyTime 距离现在 10 分钟已上的也要更新
const result = uids
.map((uid) => {
const cacheUser = userCachedList[uid]
return { uid, lastModifyTime: cacheUser?.lastModifyTime }
})
.filter((item) => !item.lastModifyTime || isDiffNow10Min(item.lastModifyTime))
if (!result.length) return
const itemIdSet: Set<number> = new Set()
const { data } = await apis.getUserInfoBatch(result)
data?.forEach((item: CacheUserItem) => {
// 更新最后更新时间。
userCachedList[item.uid] = {
...(item?.needRefresh ? item : userCachedList[item.uid]),
needRefresh: undefined,
lastModifyTime: Date.now()
}
// 收集徽章 id并缓存
// 可以改成 itemIds可以更快收集完成。
const wearingItemId = item.wearingItemId
wearingItemId && itemIdSet.add(wearingItemId)
})
// 批量请求徽章详情
await getBatchBadgeInfo([...itemIdSet])
}
/** 批量获取用户徽章详细信息 */
const getBatchBadgeInfo = async (itemIds: number[]) => {
// 没有 lastModifyTime 的要更新lastModifyTime 距离现在 10 分钟已上的也要更新
const result = itemIds
.map((itemId) => {
const cacheBadge = badgeCachedList[itemId]
return { itemId, lastModifyTime: cacheBadge?.lastModifyTime }
})
.filter((item) => !item.lastModifyTime || isDiffNow10Min(item.lastModifyTime))
if (!result.length) return
const { data } = await apis.getBadgesBatch(result)
data?.forEach(
(item: CacheBadgeItem) =>
// 更新最后更新时间。
(badgeCachedList[item.itemId] = {
...(item?.needRefresh ? item : badgeCachedList[item.itemId]),
needRefresh: undefined,
lastModifyTime: Date.now()
})
)
}
/** 房间内的所有群成员列表-@专用 */
const initAllUserBaseInfo = async () => {
if (localStorage.getItem('IS_INIT_USER_BASE') === null) {
const { data } = await apis.getAllUserBaseInfo({ params: { roomId: currentRoomId.value } })
data?.forEach((item: CacheUserItem) => (userCachedList[item.uid] = item))
localStorage.setItem('IS_INIT_USER_BASE', 'true')
}
}
const getGroupAtUserBaseInfo = async () => {
if (currentRoomId.value === 1) return
const { data } = await apis.getAllUserBaseInfo({ params: { roomId: currentRoomId.value } })
currentAtUsersList.value = data
}
/**
*
* @param searchKey
*/
// const filterUsers = (searchKey: string) => {
// // 需要过滤自己
// return currentAtUsersList.value?.filter((item) => item.name?.startsWith(searchKey))
// }
/**
* ID列表获取用户基本信息
* @param uidList
*/
const filterUsersByUidList = (uidList: number[]) => {
return currentAtUsersList.value.filter((user) => uidList.includes(user.uid))
}
return {
userCachedList,
badgeCachedList,
getBatchUserInfo,
getBatchBadgeInfo,
initAllUserBaseInfo,
getGroupAtUserBaseInfo,
currentAtUsersList,
filterUsersByUidList
}
})

464
src/stores/chat.ts Normal file
View File

@ -0,0 +1,464 @@
import { defineStore } from 'pinia'
import { useRoute } from 'vue-router'
import apis from '@/services/apis'
import type { MarkItemType, MessageType, RevokedMsgType, SessionItem } from '@/services/types'
import { MarkEnum, MsgEnum, RoomTypeEnum } from '@/enums'
import { computedTimeBlock } from '@/utils/ComputedTime.ts'
import { useCachedStore } from '@/stores/cached'
import { useUserStore } from '@/stores/user'
import { useGlobalStore } from '@/stores/global'
import { useGroupStore } from '@/stores/group'
import { useContactStore } from '@/stores/contacts'
import { cloneDeep } from 'lodash-es'
export const pageSize = 20
// 标识是否第一次请求
let isFirstInit = false
export const useChatStore = defineStore('chat', () => {
const route = useRoute()
const cachedStore = useCachedStore()
const userStore = useUserStore()
const globalStore = useGlobalStore()
const groupStore = useGroupStore()
const contactStore = useContactStore()
const sessionList = reactive<SessionItem[]>([]) // 会话列表
const sessionOptions = reactive({ isLast: false, isLoading: false, cursor: '' })
const currentRoomId = computed(() => globalStore.currentSession?.roomId)
const currentRoomType = computed(() => globalStore.currentSession?.type)
const messageMap = reactive<Map<number, Map<number, MessageType>>>(new Map([[currentRoomId.value, new Map()]])) // 消息Map
const messageOptions = reactive<Map<number, { isLast: boolean; isLoading: boolean; cursor: string }>>(
new Map([[currentRoomId.value, { isLast: false, isLoading: false, cursor: '' }]])
)
const replyMapping = reactive<Map<number, Map<number, number[]>>>(new Map([[currentRoomId.value, new Map()]])) // 回复消息映射
const currentMessageMap = computed({
get: () => {
const current = messageMap.get(currentRoomId.value as number)
if (current === undefined) {
messageMap.set(currentRoomId.value, new Map())
}
return messageMap.get(currentRoomId.value as number)
},
set: (val) => {
messageMap.set(currentRoomId.value, val as Map<number, MessageType>)
}
})
const currentMessageOptions = computed({
get: () => {
const current = messageOptions.get(currentRoomId.value as number)
if (current === undefined) {
messageOptions.set(currentRoomId.value, { isLast: false, isLoading: true, cursor: '' })
}
return messageOptions.get(currentRoomId.value as number)
},
set: (val) => {
messageOptions.set(currentRoomId.value, val as { isLast: boolean; isLoading: boolean; cursor: string })
}
})
const currentReplyMap = computed({
get: () => {
const current = replyMapping.get(currentRoomId.value as number)
if (current === undefined) {
replyMapping.set(currentRoomId.value, new Map())
}
return replyMapping.get(currentRoomId.value as number)
},
set: (val) => {
replyMapping.set(currentRoomId.value, val as Map<number, number[]>)
}
})
const isGroup = computed(() => currentRoomType.value === RoomTypeEnum.GROUP)
/**
*
*/
const currentSessionInfo = computed(() =>
sessionList.find((session) => session.roomId === globalStore.currentSession.roomId)
)
const chatListToBottomAction = ref<() => void>() // 外部提供消息列表滚动到底部事件
// const isStartCount = ref(false) // 是否开始计数
// const newMsgCount = ref(0) // 新消息计数
const newMsgCount = reactive<Map<number, { count: number; isStart: boolean }>>(
new Map([
[
currentRoomId.value,
{
// 新消息计数
count: 0,
// 是否开始计数
isStart: false
}
]
])
)
const currentNewMsgCount = computed({
get: () => {
const current = newMsgCount.get(currentRoomId.value as number)
if (current === undefined) {
newMsgCount.set(currentRoomId.value, { count: 0, isStart: false })
}
return newMsgCount.get(currentRoomId.value as number)
},
set: (val) => {
newMsgCount.set(currentRoomId.value, val as { count: number; isStart: boolean })
}
})
watch(currentRoomId, (val, oldVal) => {
if (oldVal !== undefined && val !== oldVal) {
// // 切换会话,滚动到底部
chatListToBottomAction.value?.()
// 切换的 rooms是空数据的话就请求消息列表
if (!currentMessageMap.value || currentMessageMap.value.size === 0) {
if (!currentMessageMap.value) {
messageMap.set(currentRoomId.value as number, new Map())
}
getMsgList()
}
// 群组的时候去请求
if (currentRoomType.value === RoomTypeEnum.GROUP) {
groupStore.getGroupUserList(true)
groupStore.getCountStatistic()
cachedStore.getGroupAtUserBaseInfo()
}
}
// 重置当前回复的消息
currentMsgReply.value = {}
})
// 当前消息回复
const currentMsgReply = ref<Partial<MessageType>>({})
// 将消息列表转换为数组
const chatMessageList = computed(() => [...(currentMessageMap.value?.values() || [])])
const getMsgList = async (size = pageSize) => {
currentMessageOptions.value && (currentMessageOptions.value.isLoading = true)
const { data } = await apis
.getMsgList({
params: {
pageSize: size,
cursor: currentMessageOptions.value?.cursor,
roomId: currentRoomId.value
}
})
.finally(() => {
currentMessageOptions.value && (currentMessageOptions.value.isLoading = false)
})
if (!data) return
const computedList = computedTimeBlock(data.list)
/** 收集需要请求用户详情的 uid */
const uidCollectYet: Set<number> = new Set() // 去重用
computedList.forEach((msg) => {
const replyItem = msg.message.body?.reply
if (replyItem?.id) {
const messageIds = currentReplyMap.value?.get(replyItem.id) || []
messageIds.push(msg.message.id)
currentReplyMap.value?.set(replyItem.id, messageIds)
// 查询被回复用户的信息,被回复的用户信息里暂时无 uid
// collectUidItem(replyItem.uid)
}
// 查询消息发送者的信息
uidCollectYet.add(msg.fromUser.uid)
})
// 获取用户信息缓存
await cachedStore.getBatchUserInfo([...uidCollectYet])
// 为保证获取的历史消息在前面
const newList = [...computedList, ...chatMessageList.value]
currentMessageMap.value?.clear() // 清空Map
newList.forEach((msg) => {
currentMessageMap.value?.set(msg.message.id, msg)
})
if (currentMessageOptions.value) {
currentMessageOptions.value.cursor = data.cursor
currentMessageOptions.value.isLast = data.isLast
currentMessageOptions.value.isLoading = false
}
}
const getSessionList = async (isFresh = false) => {
if (!isFresh && (sessionOptions.isLast || sessionOptions.isLoading)) return
sessionOptions.isLoading = true
const response = await apis
.getSessionList({
params: {
pageSize: sessionList.length > pageSize ? sessionList.length : pageSize,
cursor: isFresh || !sessionOptions.cursor ? undefined : sessionOptions.cursor
}
})
.catch(() => {
sessionOptions.isLoading = false
})
if (!response) return
const { data } = response
isFresh ? sessionList.splice(0, sessionList.length, ...data.list) : sessionList.push(...data.list)
sessionOptions.cursor = data.cursor
sessionOptions.isLast = data.isLast
sessionOptions.isLoading = false
sortAndUniqueSessionList()
sessionList[0].unreadCount = 0
if (!isFirstInit) {
isFirstInit = true
globalStore.currentSession.roomId = data.list[0].roomId
globalStore.currentSession.type = data.list[0].type
// 用会话列表第一个去请求消息列表
await getMsgList()
// 请求第一个群成员列表
currentRoomType.value === RoomTypeEnum.GROUP && (await groupStore.getGroupUserList(true))
// 初始化所有用户基本信息
userStore.isSign && (await cachedStore.initAllUserBaseInfo())
// 联系人列表
await contactStore.getContactList(true)
}
}
/** 会话列表去重并排序 */
const sortAndUniqueSessionList = () => {
const temp: Record<string, SessionItem> = {}
sessionList.forEach((item) => (temp[item.roomId] = item))
sessionList.splice(0, sessionList.length, ...Object.values(temp))
sessionList.sort((pre, cur) => cur.activeTime - pre.activeTime)
}
const updateSession = (roomId: number, roomProps: Partial<SessionItem>) => {
const session = sessionList.find((item) => item.roomId === roomId)
session && roomProps && Object.assign(session, roomProps)
sortAndUniqueSessionList()
}
const updateSessionLastActiveTime = (roomId: number, room?: SessionItem) => {
const session = sessionList.find((item) => item.roomId === roomId)
if (session) {
Object.assign(session, { activeTime: Date.now() })
} else if (room) {
const newItem = cloneDeep(room)
newItem.activeTime = Date.now()
sessionList.unshift(newItem)
}
sortAndUniqueSessionList()
}
// 通过房间ID获取会话信息
const getSession = (roomId: number): SessionItem => {
return sessionList.find((item) => item.roomId === roomId) as SessionItem
}
const pushMsg = async (msg: MessageType) => {
const current = messageMap.get(msg.message.roomId)
// TODO 操过五分钟发送信息的时候没有显示时间差的时间戳 (nyh -> 2024-05-21 00:17:15)
current?.set(msg.message.id, msg)
// 获取用户信息缓存
// 尝试取缓存user, 如果有 lastModifyTime 说明缓存过了,没有就一定是要缓存的用户了
const uid = msg.fromUser.uid
// const cacheUser = cachedStore.userCachedList[uid]
await cachedStore.getBatchUserInfo([uid])
// 发完消息就要刷新会话列表,
// 如果当前会话已经置顶了,可以不用刷新
if (globalStore.currentSession && globalStore.currentSession.roomId !== msg.message.roomId) {
let detailResponse = undefined
// 如果当前路由不是聊天,就开始拿会话详情,并手动新增一条会话记录
// if (route?.path && route?.path !== '/') {
// globalStore.currentSession.roomId = msg.message.roomId
// globalStore.currentSession.type = RoomTypeEnum.Single
if (!current) {
detailResponse = await apis.sessionDetail({ id: msg.message.roomId })
}
if (detailResponse) {
const { data } = detailResponse
updateSessionLastActiveTime(msg.message.roomId, data)
}
}
// 如果收到的消息里面是艾特自己的就发送系统通知
// if (msg.message.body.atUidList?.includes(userStore.userInfo.uid) && cacheUser) {
// notify({
// name: cacheUser.name as string,
// text: msg.message.body.content,
// icon: cacheUser.avatar as string
// })
// }
// tab 在后台获得新消息,就开始闪烁!
// if (document.hidden && !shakeTitle.isShaking) {
// shakeTitle.start()
// }
if (
currentNewMsgCount.value &&
currentNewMsgCount.value?.isStart &&
typeof currentNewMsgCount.value.count === 'number'
) {
currentNewMsgCount.value.count++
return
}
// 聊天记录计数
if (currentRoomId.value !== msg.message.roomId) {
const item = sessionList.find((item) => item.roomId === msg.message.roomId)
if (item) {
item.unreadCount += 1
}
// 如果新消息的 roomId 和 当前显示的 room 的 Id 一致,就更新已读
} else {
// 且当前路由在 聊天 内
if (route?.path && route?.path === '/message') {
await apis.markMsgRead({ roomId: currentRoomId.value })
}
}
// 如果当前路由不是聊天,就开始计数
if (route?.path && route?.path !== '/message') {
globalStore.unReadMark.newMsgUnreadCount++
}
// 聊天列表滚动到底部
setTimeout(() => {
// 如果超过一屏了,不自动滚动到最新消息。
chatListToBottomAction.value?.()
}, 0)
}
// 过滤掉小黑子的发言
const filterUser = (uid: number) => {
if (typeof uid !== 'number') return
for (const messages of messageMap.values()) {
messages?.forEach((msg) => {
if (msg.fromUser.uid === uid) {
messages.delete(msg.message.id)
}
})
}
}
const loadMore = async (size?: number) => {
if (currentMessageOptions.value?.isLast || currentMessageOptions.value?.isLoading) return
await getMsgList(size)
}
const clearNewMsgCount = () => {
currentNewMsgCount.value && (currentNewMsgCount.value.count = 0)
}
// 查找消息在列表里面的索引
const getMsgIndex = (msgId: number) => {
if (!msgId || isNaN(Number(msgId))) return -1
const keys = currentMessageMap.value ? Array.from(currentMessageMap.value.keys()) : []
return keys.findIndex((key) => key === msgId)
}
// 更新点赞、举报数
const updateMarkCount = (markList: MarkItemType[]) => {
markList.forEach((mark: MarkItemType) => {
const { msgId, markType, markCount } = mark
const msgItem = currentMessageMap.value?.get(msgId)
if (msgItem) {
if (markType === MarkEnum.LIKE) {
msgItem.message.messageMark.likeCount = markCount
} else if (markType === MarkEnum.DISLIKE) {
msgItem.message.messageMark.dislikeCount = markCount
}
}
})
}
// 更新消息撤回状态
const updateRecallStatus = (data: RevokedMsgType) => {
const { msgId } = data
const message = currentMessageMap.value?.get(msgId)
if (message && typeof data.recallUid === 'number') {
message.message.type = MsgEnum.RECALL
const cacheUser = cachedStore.userCachedList[data.recallUid]
// 如果撤回者的 id 不等于消息发送人的 id, 或者你本人就是管理员,那么显示管理员撤回的。
if (data.recallUid !== message.fromUser.uid) {
message.message.body = `管理员"${cacheUser.name}"撤回了一条成员消息` // 后期根据本地用户数据修改
} else {
// 如果被撤回的消息是消息发送者撤回,正常显示
message.message.body = `"${cacheUser.name}"撤回了一条消息` // 后期根据本地用户数据修改
}
}
// 更新与这条撤回消息有关的消息
const messageList = currentReplyMap.value?.get(msgId)
messageList?.forEach((id) => {
const msg = currentMessageMap.value?.get(id)
if (msg) {
msg.message.body.reply.body = '原消息已被撤回'
}
})
}
// 删除消息
const deleteMsg = (msgId: number) => {
currentMessageMap.value?.delete(msgId)
}
// 更新消息
const updateMsg = (msgId: number, newMessage: MessageType) => {
deleteMsg(msgId)
pushMsg(newMessage)
}
// 标记已读数为 0
const markSessionRead = (roomId: number) => {
const session = sessionList.find((item) => item.roomId === roomId)
const unreadCount = session?.unreadCount || 0
if (session) {
session.unreadCount = 0
}
return unreadCount
}
// 根据消息id获取消息体
const getMessage = (messageId: number) => {
return currentMessageMap.value?.get(messageId)
}
// 删除会话
const removeContact = (roomId: number) => {
const index = sessionList.findIndex((session) => session.roomId === roomId)
sessionList.splice(index, 1)
}
return {
getMsgIndex,
chatMessageList,
pushMsg,
deleteMsg,
clearNewMsgCount,
updateMarkCount,
updateRecallStatus,
updateMsg,
chatListToBottomAction,
newMsgCount,
messageMap,
currentMessageMap,
currentMessageOptions,
currentReplyMap,
currentNewMsgCount,
loadMore,
currentMsgReply,
filterUser,
sessionList,
sessionOptions,
getSessionList,
updateSession,
updateSessionLastActiveTime,
markSessionRead,
getSession,
isGroup,
currentSessionInfo,
getMessage,
removeContact
}
})

111
src/stores/contacts.ts Normal file
View File

@ -0,0 +1,111 @@
import { defineStore } from 'pinia'
import apis from '@/services/apis'
import { useGlobalStore } from '@/stores/global'
import type { ContactItem, RequestFriendItem } from '@/services/types'
import { RequestFriendAgreeStatus } from '@/services/types'
export const pageSize = 20
export const useContactStore = defineStore('contact', () => {
const globalStore = useGlobalStore()
const contactsList = reactive<ContactItem[]>([])
const requestFriendsList = reactive<RequestFriendItem[]>([])
const contactsOptions = reactive({ isLast: false, isLoading: false, cursor: '' })
const requestFriendsOptions = reactive({ isLast: false, isLoading: false, cursor: '' })
const getContactList = async (isFresh = false) => {
if (!isFresh) {
if (contactsOptions.isLast || contactsOptions.isLoading) return
}
contactsOptions.isLoading = true
const res = await apis
.getContactList({
// TODO 先写 100稍后优化
pageSize: 100,
cursor: isFresh || !contactsOptions.cursor ? undefined : contactsOptions.cursor
})
.catch(() => {
contactsOptions.isLoading = false
})
if (!res) return
const { data } = res
isFresh ? contactsList.splice(0, contactsList.length, ...data.list) : contactsList.push(...data.list)
contactsOptions.cursor = data.cursor
contactsOptions.isLast = data.isLast
contactsOptions.isLoading = false
}
/** 好友申请未读数 */
const getNewFriendCount = async () => {
const res = await apis.newFriendCount().catch(() => {
//
})
if (!res) return
const { data } = res
if (typeof data?.unReadCount === 'number') {
globalStore.unReadMark.newFriendUnreadCount = data.unReadCount
}
}
const getRequestFriendsList = async (isFresh = false) => {
if (!isFresh) {
if (requestFriendsOptions.isLast || requestFriendsOptions.isLoading) return
}
requestFriendsOptions.isLoading = true
const res = await apis
.requestFriendList({
pageSize,
cursor: isFresh || !requestFriendsOptions.cursor ? undefined : requestFriendsOptions.cursor
})
.catch(() => {
requestFriendsOptions.isLoading = false
})
// 每次加载完新的好友邀请列表都要更新申请未读数
await getNewFriendCount()
if (!res) return
const { data } = res
isFresh
? requestFriendsList.splice(0, requestFriendsList.length, ...data.list)
: requestFriendsList.push(...data.list)
requestFriendsOptions.cursor = data.cursor
requestFriendsOptions.isLast = data.isLast
requestFriendsOptions.isLoading = false
}
// 默认执行一次
// getContactList()
// getRequestFriendsList()
/** 接受好友请求 */
const onAcceptFriend = (applyId: number) => {
// 同意好友申请
apis.applyFriendRequest({ applyId }).then(() => {
// 刷新好友申请列表
getRequestFriendsList(true)
// 刷新好友列表
getContactList(true)
// 标识为可以发消息的人
if (globalStore.currentSelectedContact) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
globalStore.currentSelectedContact.status = RequestFriendAgreeStatus.Agree
}
})
}
/** 删除好友 */
const onDeleteContact = async (uid: number) => {
if (!uid) return
// 同意好友申请
await apis.deleteFriend({ targetUid: uid })
// 刷新好友申请列表
// getRequestFriendsList(true)
// 刷新好友列表
await getContactList(true)
}
return {
getContactList,
getRequestFriendsList,
contactsList,
requestFriendsList,
contactsOptions,
requestFriendsOptions,
onAcceptFriend,
onDeleteContact
}
})

View File

@ -0,0 +1,81 @@
import { defineStore } from 'pinia'
import { useDownload } from '@/hooks/useDownload.ts'
type DownloadObjType = {
url: string
isDownloading: boolean
process: number | undefined
}
// 定义一个下载队列的 store
export const useDownloadQuenuStore = defineStore('downloadQuenu', () => {
// 最多可同时执行下载的任务数量
const maxDownloadCount = 1
// 下载队列
const quenu = reactive<string[]>([])
// 下载对象
const downloadObjMap = reactive<Map<string, DownloadObjType>>(new Map())
// 添加到下载队列
const addQuenuAction = (url: string) => {
quenu.push(url)
}
// 从下载队列中移除
const removeQuenuAction = (url: string) => {
const index = quenu.indexOf(url)
if (index > -1) {
quenu.splice(index, 1)
}
}
// 出队列
const dequeue = () => {
if (!quenu.length || downloadObjMap.size >= maxDownloadCount) {
return
}
const url = quenu.shift()
if (url) {
downloadAction(url)
}
}
// 下载
const downloadAction = (url: string) => {
const { downloadFile, isDownloading, process, onLoaded } = useDownload()
const stopWatcher = watch(process, () => {
// 更新下载进度
downloadObjMap.set(url, { url, isDownloading: isDownloading.value, process: process.value })
})
onLoaded(() => {
stopWatcher() // 清除watcher
downloadObjMap.delete(url) // 下载完成后 删除下载对象
dequeue()
})
if (url) {
downloadFile(url)
}
}
const download = (url: string) => {
addQuenuAction(url)
dequeue()
}
// 取消下载
const cancelDownload = (url: string) => {
if (quenu.includes(url)) {
removeQuenuAction(url)
}
}
return {
quenu,
addQuenuAction,
removeQuenuAction,
dequeue,
downloadObjMap,
download,
cancelDownload
}
})

55
src/stores/emoji.ts Normal file
View File

@ -0,0 +1,55 @@
import { defineStore } from 'pinia'
import apis from '@/services/apis'
import type { EmojiItem } from '@/services/types'
import { useUserStore } from '@/stores/user'
export const useEmojiStore = defineStore('emoji', () => {
const isLoading = ref(false) // 是否正在加载
const userStore = useUserStore()
const emojiList = ref<EmojiItem[]>([])
/**
*
*/
const getEmojiList = async () => {
isLoading.value = true
const res = await apis.getEmoji({ uid: userStore.userInfo.uid! }).catch(() => {
isLoading.value = false
})
if (!res) return
const {
data: { data }
} = res
emojiList.value = data
}
/**
*
*/
const addEmoji = async (emojiUrl: string) => {
const { uid } = userStore.userInfo
if (!uid || !emojiUrl) return
apis.addEmoji({ uid, expressionUrl: emojiUrl }).then((res) => {
if (res.success) {
window.$message.success('添加成功')
}
})
await getEmojiList()
}
/**
*
*/
const deleteEmoji = async (id: number) => {
if (!id) return
await apis.deleteEmoji({ id })
await getEmojiList()
}
return {
emojiList,
addEmoji,
getEmojiList,
deleteEmoji
}
})

65
src/stores/global.ts Normal file
View File

@ -0,0 +1,65 @@
import { defineStore } from 'pinia'
import { RoomTypeEnum } from '@/enums'
import { useChatStore } from '@/stores/chat'
import type { ContactItem, RequestFriendItem } from '@/services/types'
import { clearQueue, readCountQueue } from '@/utils/ReadCountQueue.ts'
import apis from '@/services/apis'
export const useGlobalStore = defineStore('global', () => {
const chatStore = useChatStore()
const unReadMark = reactive<{ newFriendUnreadCount: number; newMsgUnreadCount: number }>({
newFriendUnreadCount: 0,
newMsgUnreadCount: 0
})
const currentReadUnreadList = reactive<{ show: boolean; msgId: number | null }>({
show: false,
msgId: null
})
const currentSession = reactive<{ roomId: number; type: RoomTypeEnum }>({
roomId: 1,
type: RoomTypeEnum.GROUP
})
/** 点击联系人选中的联系人项 */
const currentSelectedContact = ref<ContactItem | RequestFriendItem>()
const addFriendModalInfo = reactive<{ show: boolean; uid?: number }>({
show: false,
uid: undefined
})
// 创建群聊
const createGroupModalInfo = reactive<{
show: boolean
isInvite: boolean
selectedUid: number[]
}>({
show: false,
isInvite: false,
selectedUid: []
})
// 切换会话的时候重置消息已读数查询
watch(currentSession, (val) => {
// 清理已读数查询
clearQueue()
setTimeout(readCountQueue, 1000)
// 标记房间最新消息已读
apis.markMsgRead({ roomId: val.roomId })
const unreadCount = chatStore.markSessionRead(val.roomId)
// console.log(unReadMark.newMsgUnreadCount, unreadCount)
// setTimeout(() => {
// if (unReadMark.newMsgUnreadCount !== unReadMark.newMsgUnreadCount - unreadCount) {
// // unReadMark.newMsgUnreadCount = unReadMark.newMsgUnreadCount - unreadCount
// }
// })
const resultCount = unReadMark.newMsgUnreadCount - unreadCount
unReadMark.newMsgUnreadCount = resultCount > 0 ? resultCount : 0
})
return {
unReadMark,
currentSession,
addFriendModalInfo,
currentSelectedContact,
currentReadUnreadList,
createGroupModalInfo
}
})

208
src/stores/group.ts Normal file
View File

@ -0,0 +1,208 @@
import apis from '@/services/apis'
import { defineStore } from 'pinia'
import { useGlobalStore } from '@/stores/global'
import type { GroupDetailReq, UserItem } from '@/services/types'
import { pageSize, useChatStore } from './chat'
import { OnlineEnum, RoleEnum } from '@/enums'
import { uniqueUserList } from '@/utils/unique'
import { useCachedStore } from '@/stores/cached'
import { useUserStore } from '@/stores/user'
import { cloneDeep } from 'lodash-es'
const sorAction = (pre: UserItem, next: UserItem) => {
if (pre.activeStatus === OnlineEnum.ONLINE && next.activeStatus === OnlineEnum.ONLINE) {
return next.lastOptTime < pre.lastOptTime ? -1 : 1
} else if (pre.activeStatus !== OnlineEnum.ONLINE && next.activeStatus !== OnlineEnum.ONLINE) {
return next.lastOptTime < pre.lastOptTime ? -1 : 1
} else if (pre.activeStatus === OnlineEnum.ONLINE && next.activeStatus !== OnlineEnum.ONLINE) {
return -1
} else if (pre.activeStatus !== OnlineEnum.ONLINE && next.activeStatus === OnlineEnum.ONLINE) {
return 1
} else {
return next.lastOptTime < pre.lastOptTime ? -1 : 1
}
}
export const useGroupStore = defineStore('group', () => {
const cachedStore = useCachedStore()
const globalStore = useGlobalStore()
const userStore = useUserStore()
const chatStore = useChatStore()
// 消息列表
const userList = ref<UserItem[]>([])
const userListOptions = reactive({ isLast: false, loading: true, cursor: '' })
const currentRoomId = computed(() => globalStore.currentSession?.roomId)
/**
* ID
*/
const currentLordId = computed(() => {
const list = userList.value.filter((member) => member.roleId === RoleEnum.LORD)
if (list.length) {
return list[0]?.uid
}
return -99
})
/**
* ID列表
*/
const adminUidList = computed(() => {
return userList.value.filter((member) => member.roleId === RoleEnum.ADMIN).map((member) => member.uid)
})
/**
*
*/
const adminList = computed(() => {
return cachedStore.filterUsersByUidList(adminUidList.value)
})
/**
*
*/
const memberList = computed(() => {
const memberInfoList = cachedStore.filterUsersByUidList(userList.value.map((item) => item.uid))
return memberInfoList.map((member) => {
if (adminUidList.value.includes(member.uid)) {
return {
...member,
roleId: RoleEnum.ADMIN
}
} else if (member.uid === currentLordId.value) {
return {
...member,
roleId: RoleEnum.LORD
}
}
return member
})
})
const countInfo = ref<GroupDetailReq>({
avatar: '',
groupName: '',
onlineNum: 0,
role: 0,
roomId: currentRoomId.value
})
// 移动端控制显隐
const showGroupList = ref(false)
// 获取群成员
const getGroupUserList = async (refresh = false) => {
const res = await apis.getGroupList({
params: {
pageSize,
cursor: refresh ? undefined : userListOptions.cursor,
roomId: currentRoomId.value
}
})
if (!res) return
const { data } = res
const tempNew = cloneDeep(uniqueUserList(refresh ? data.list : [...data.list, ...userList.value]))
tempNew.sort(sorAction)
userList.value = tempNew
userListOptions.cursor = data.cursor
userListOptions.isLast = data.isLast
userListOptions.loading = false
/** 收集需要请求用户详情的 uid */
const uidCollectYet: Set<number> = new Set() // 去重用
data.list?.forEach((user: UserItem) => uidCollectYet.add(user.uid))
// 获取用户信息缓存
await cachedStore.getBatchUserInfo([...uidCollectYet])
}
// 获取群成员数量统计
const getCountStatistic = async () => {
const { data } = await apis.groupDetail({ id: currentRoomId.value })
countInfo.value = data
}
// 加载更多群成员
const loadMore = async () => {
if (userListOptions.isLast) return
await getGroupUserList()
}
// 更新用户在线状态
const batchUpdateUserStatus = (items: UserItem[]) => {
const tempNew = cloneDeep(userList.value)
for (let index = 0, len = items.length; index < len; index++) {
const curUser = items[index]
const findIndex = tempNew.findIndex((item) => item.uid === curUser.uid)
findIndex > -1 && (tempNew[findIndex].activeStatus = curUser.activeStatus)
}
tempNew.sort(sorAction)
userList.value = tempNew
}
// 过滤掉小黑子
const filterUser = (uid: number) => {
if (typeof uid !== 'number') return
userList.value = userList.value.filter((item) => item.uid !== uid)
}
/**
*
* @param uidList
*/
const addAdmin = async (uidList: number[]) => {
await apis.addAdmin({ roomId: currentRoomId.value, uidList })
// 更新群成员列表
userList.value.forEach((user) => {
if (uidList.includes(user.uid)) {
user.roleId = RoleEnum.ADMIN
}
})
}
/**
*
* @param uidList
*/
const revokeAdmin = async (uidList: number[]) => {
await apis.revokeAdmin({ roomId: currentRoomId.value, uidList })
// 更新群成员列表
userList.value.forEach((user) => {
if (uidList.includes(user.uid)) {
user.roleId = RoleEnum.NORMAL
}
})
}
/**
* 退
*/
const exitGroup = async () => {
await apis.exitGroup({ roomId: currentRoomId.value })
// 更新群成员列表
const index = userList.value.findIndex((user) => user.uid === userStore.userInfo.uid)
userList.value.splice(index, 1)
// 更新会话列表
chatStore.removeContact(currentRoomId.value)
// 切换为第一个会话
globalStore.currentSession.roomId = chatStore.sessionList[0].roomId
}
return {
userList,
userListOptions,
loadMore,
getGroupUserList,
getCountStatistic,
currentLordId,
countInfo,
batchUpdateUserStatus,
showGroupList,
filterUser,
adminUidList,
adminList,
memberList,
addAdmin,
revokeAdmin,
exitGroup
}
})

105
src/stores/preview.ts Normal file
View File

@ -0,0 +1,105 @@
import { defineStore } from 'pinia'
export const useImgPreviewStore = defineStore('image', () => {
const previewUrl = ref('') // 图片预览链接
const isShowing = ref(false) // 图片预览是否显示
/**
*
* @param url
*/
const show = (url: string) => {
previewUrl.value = url
isShowing.value = true
}
/**
*
*/
const close = () => {
isShowing.value = false
previewUrl.value = ''
}
return {
previewUrl,
isShowing,
show,
close
}
})
/**
* Store
*/
export const useVoicePreviewStore = defineStore('voice', () => {
const previewUrl = ref('') // 音频预览链接
const isPlaying = ref(false) // 音频是否正在播放
const progress = ref(0) // 音频播放进度
const audio = ref<HTMLAudioElement | null>(null) // 音频播放器
/**
*
*/
const close = () => {
isPlaying.value = false
progress.value = 0
audio.value?.pause()
// 显式移除事件监听
audio.value?.removeEventListener('ended', () => {})
audio.value = null
}
/**
*
*/
const open = async (url: string) => {
if (isPlaying.value && previewUrl.value === url) {
close()
return
}
close()
previewUrl.value = url
audio.value = new Audio(url)
await audio.value.play()
isPlaying.value = true
audio.value.addEventListener('ended', () => close())
}
return {
previewUrl,
isPlaying,
close,
open
}
})
/**
* Store
*/
export const useVideoPreviewStore = defineStore('video', () => {
const previewUrl = ref('') // 预览链接
const isPlaying = ref(false) // 是否正在播放
/**
*
*/
const open = (url: string) => {
previewUrl.value = url
isPlaying.value = true
}
/**
*
*/
const close = () => {
previewUrl.value = ''
isPlaying.value = false
}
return {
previewUrl,
isPlaying,
open,
close
}
})

View File

@ -1,6 +1,10 @@
import { defineStore } from 'pinia'
import { CloseBxEnum, StoresEnum, ThemeEnum } from '@/enums'
import apis from '@/services/apis.ts'
import { isDiffNow10Min } from '@/utils/ComputedTime.ts'
import type { CacheBadgeItem } from '@/services/types.ts'
const badgeCachedList = reactive<Record<number, Partial<CacheBadgeItem>>>({})
// TODO 使用indexDB或者把配置写出到文件中还需要根据每个账号来进行配置 (nyh -> 2024-03-26 01:22:12)
export const setting = defineStore(StoresEnum.SETTING, {
state: (): STO.Setting => ({
@ -26,8 +30,10 @@ export const setting = defineStore(StoresEnum.SETTING, {
password: '',
name: '',
avatar: '',
uid: ''
}
uid: 0,
token: ''
},
badgeList: []
},
/** 聊天设置 */
chat: {
@ -66,9 +72,30 @@ export const setting = defineStore(StoresEnum.SETTING, {
setAccountInfo(accountInfo: STO.Setting['login']['accountInfo']) {
this.login.accountInfo = accountInfo
},
/** 批量获取用户徽章详细信息 */
async getBatchBadgeInfo(itemIds: number[]) {
// 没有 lastModifyTime 的要更新lastModifyTime 距离现在 10 分钟已上的也要更新
const result = itemIds
.map((itemId) => {
const cacheBadge = badgeCachedList[itemId]
return { itemId, lastModifyTime: cacheBadge?.lastModifyTime }
})
.filter((item) => !item.lastModifyTime || isDiffNow10Min(item.lastModifyTime))
if (!result.length) return
const { data } = await apis.getBadgesBatch(result)
data?.forEach(
(item: CacheBadgeItem) =>
// 更新最后更新时间。
(badgeCachedList[item.itemId] = {
...(item?.needRefresh ? item : badgeCachedList[item.itemId]),
needRefresh: void 0,
lastModifyTime: Date.now()
})
)
},
/** 清空账号信息 */
clearAccount() {
this.login.accountInfo = { account: '', avatar: '', name: '', password: '', uid: '' }
this.login.accountInfo.password = ''
}
},
share: {

View File

@ -1,34 +1,36 @@
import { ref } from 'vue'
import apis from '@/services/apis'
import { defineStore } from 'pinia'
import type { UserInfoType } from '@/services/types'
type IState = {
loginInfo: {
avatarUrl: string
email: string
nickname: string
ipaddress: string
username: string
token: string
}
}
export const useUserStore = defineStore('user', () => {
const userInfo = ref<Partial<UserInfoType>>({})
const isSign = ref(false)
export const userStore = defineStore('localUserInfo', {
state: (): IState =>
<IState>{
loginInfo: {}
},
getters: {
getBearerToken(): any {
return this.loginInfo.token ? this.loginInfo.token : ''
}
},
actions: {
setLoginInfo(loginInfo: any) {
this.loginInfo = loginInfo
},
logout() {
this.$reset()
//删除localStorage中的用户信息
localStorage.removeItem('localUserInfo')
}
let localUserInfo = {}
try {
localUserInfo = JSON.parse(localStorage.getItem('USER_INFO') || '{}')
} catch (error) {
localUserInfo = {}
}
// 从 local读取
if (!Object.keys(userInfo.value).length && Object.keys(localUserInfo).length) {
userInfo.value = localUserInfo
}
function getUserDetailAction() {
apis
.getUserDetail()
.then((res) => {
userInfo.value = { ...userInfo.value, ...res.data.data }
})
.catch(() => {
// 删除缓存
localStorage.removeItem('TOKEN')
localStorage.removeItem('USER_INFO')
})
}
return { userInfo, isSign, getUserDetailAction }
})

30
src/stores/ws.ts Normal file
View File

@ -0,0 +1,30 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import wsIns from '@/services/webSocket.ts'
import { WsRequestMsgType } from '@/utils/wsType'
export enum LoginStatus {
Init,
Waiting,
Success
}
export const useWsLoginStore = defineStore('wsLogin', () => {
const loginQrCode = ref<string>()
const loginStatus = ref(LoginStatus.Init)
function getLoginQrCode() {
wsIns.send({ type: WsRequestMsgType.RequestLoginQrCode })
}
function resetLoginState() {
loginQrCode.value = undefined
loginStatus.value = LoginStatus.Init
}
return {
loginQrCode,
loginStatus,
resetLoginState,
getLoginQrCode
}
})

View File

@ -28,7 +28,7 @@
}
.photo-wall {
@extend .bubble-oneself;
@apply flex flex-col items-start gap-6px max-w-380px;
@apply flex flex-col items-start gap-6px flex-1;
}
.float-header {
@include float;
@ -66,11 +66,7 @@
}
}
.content-span {
width: fit-content;
max-width: 250px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@apply w-fit truncate flex-1;
}
/** 回复图片的计数器样式 */
.reply-img-sub {
@ -79,7 +75,7 @@
}
/** emoji回复气泡的样式 */
.emoji-reply-bubble {
@apply relative rounded-8px p-[4px_8px] cursor-pointer select-none bg-#13987F66 text-14px w-fit;
@apply relative rounded-50px p-[4px_8px] cursor-pointer select-none bg-#13987F66 text-14px w-fit border-(1px solid #13987F);
}
/** 跳转到回复内容时候显示的样式 */
.active-reply {
@ -95,6 +91,15 @@
border-radius: 0;
transition: all 0.4s ease-in-out;
}
/** 时间搓显示时候的动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.4s ease-in-out;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/**! 替换ait的字体颜色 */
:deep(#aitSpan) {
@apply text-inherit cursor-pointer;

View File

@ -2,7 +2,7 @@
@apply flex flex-col w-180px h-100vh p-[12px_0_12px_6px] box-border select-none text-[--text-color];
.item {
height: 42px;
padding-left: 4px;
padding: 0 4px;
width: 95%;
box-sizing: border-box;
border-radius: 6px;

View File

@ -42,7 +42,7 @@
--bg-avatar: #e3e3e3;
// 个人信息框背景颜色
--info-hover: #eee;
--info-text-color: #505050;
--info-text-color: #202020;
// 系统托盘hover样式
--tray-hover: #eee;
--tray-hover-e: #f5dce1;
@ -59,6 +59,12 @@
--reply-color: #909090;
--reply-hover: #505050;
--bg-reply-img-count: #e3e3e3;
// 主页面面板分割线样式
--split-color: #f1f1f1;
// 编辑资料背景颜色
--bg-edit: #f0f0f0;
// 聊天框时间戳样式
--time-color: #e3e3e3;
}
html[data-theme='dark'] {
@ -121,6 +127,12 @@ html[data-theme='dark'] {
--reply-color: #e3e3e3;
--reply-hover: #b1b1b1;
--bg-reply-img-count: #505050;
// 主页面面板分割线样式
--split-color: #3b3b3b;
// 编辑资料背景颜色
--bg-edit: #262626;
// 聊天框时间戳样式
--time-color: #3b3b3b;
}
/**! end */
// 线性动画

View File

@ -1,22 +0,0 @@
/**!使用比较新的View Transitions API来实现主图切换效果*/
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
/** 进入dark模式和退出dark模式时两个图像的位置顺序正好相反 */
.dark::view-transition-old(root) {
z-index: 1;
}
.dark::view-transition-new(root) {
z-index: 999;
}
::view-transition-old(root) {
z-index: 999;
}
::view-transition-new(root) {
z-index: 1;
}
/**!end*/

View File

@ -55,6 +55,7 @@ declare global {
const useDialog: typeof import('naive-ui')['useDialog']
const useLoadingBar: typeof import('naive-ui')['useLoadingBar']
const useMessage: typeof import('naive-ui')['useMessage']
const useModal: typeof import('naive-ui')['useModal']
const useNotification: typeof import('naive-ui')['useNotification']
const useSlots: typeof import('vue')['useSlots']
const watch: typeof import('vue')['watch']

View File

@ -7,58 +7,58 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
ActionBar: typeof import('./../components/windows/ActionBar.vue')['default']
ChatBox: typeof import('./../components/rightBox/chatBox/index.vue')['default']
ChatFooter: typeof import('./../components/rightBox/chatBox/ChatFooter.vue')['default']
ChatHeader: typeof import('./../components/rightBox/chatBox/ChatHeader.vue')['default']
ChatMain: typeof import('./../components/rightBox/chatBox/ChatMain.vue')['default']
ChatSidebar: typeof import('./../components/rightBox/chatBox/ChatSidebar.vue')['default']
ContextMenu: typeof import('./../components/common/ContextMenu.vue')['default']
Details: typeof import('./../components/rightBox/Details.vue')['default']
Emoji: typeof import('./../components/rightBox/emoji/index.vue')['default']
Image: typeof import('./../components/rightBox/renderMessage/Image.vue')['default']
InfoPopover: typeof import('./../components/common/InfoPopover.vue')['default']
MsgInput: typeof import('./../components/rightBox/MsgInput.vue')['default']
NaiveProvider: typeof import('./../components/common/NaiveProvider.vue')['default']
NAlert: typeof import('naive-ui')['NAlert']
NAvatar: typeof import('naive-ui')['NAvatar']
NAvatarGroup: typeof import('naive-ui')['NAvatarGroup']
NBadge: typeof import('naive-ui')['NBadge']
NButton: typeof import('naive-ui')['NButton']
NButtonGroup: typeof import('naive-ui')['NButtonGroup']
NCheckbox: typeof import('naive-ui')['NCheckbox']
NCollapse: typeof import('naive-ui')['NCollapse']
NCollapseItem: typeof import('naive-ui')['NCollapseItem']
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
NDropdown: typeof import('naive-ui')['NDropdown']
NEllipsis: typeof import('naive-ui')['NEllipsis']
NFlex: typeof import('naive-ui')['NFlex']
NIcon: typeof import('naive-ui')['NIcon']
NIconWrapper: typeof import('naive-ui')['NIconWrapper']
NImage: typeof import('naive-ui')['NImage']
NImageGroup: typeof import('naive-ui')['NImageGroup']
NInput: typeof import('naive-ui')['NInput']
NLoadingBarProvider: typeof import('naive-ui')['NLoadingBarProvider']
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NModal: typeof import('naive-ui')['NModal']
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
NNumberAnimation: typeof import('naive-ui')['NNumberAnimation']
NPopover: typeof import('naive-ui')['NPopover']
NPopselect: typeof import('naive-ui')['NPopselect']
NQrCode: typeof import('naive-ui')['NQrCode']
NRadio: typeof import('naive-ui')['NRadio']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSelect: typeof import('naive-ui')['NSelect']
NSkeleton: typeof import('naive-ui')['NSkeleton']
NSwitch: typeof import('naive-ui')['NSwitch']
NTabPane: typeof import('naive-ui')['NTabPane']
NTabs: typeof import('naive-ui')['NTabs']
NTooltip: typeof import('naive-ui')['NTooltip']
NVirtualList: typeof import('naive-ui')['NVirtualList']
RenderMessage: typeof import('./../components/rightBox/renderMessage/index.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
Text: typeof import('./../components/rightBox/renderMessage/Text.vue')['default']
ActionBar: (typeof import('./../components/windows/ActionBar.vue'))['default']
ChatBox: (typeof import('./../components/rightBox/chatBox/index.vue'))['default']
ChatFooter: (typeof import('./../components/rightBox/chatBox/ChatFooter.vue'))['default']
ChatHeader: (typeof import('./../components/rightBox/chatBox/ChatHeader.vue'))['default']
ChatMain: (typeof import('./../components/rightBox/chatBox/ChatMain.vue'))['default']
ChatSidebar: (typeof import('./../components/rightBox/chatBox/ChatSidebar.vue'))['default']
ContextMenu: (typeof import('./../components/common/ContextMenu.vue'))['default']
Details: (typeof import('./../components/rightBox/Details.vue'))['default']
Emoji: (typeof import('./../components/rightBox/emoji/index.vue'))['default']
Image: (typeof import('./../components/rightBox/renderMessage/Image.vue'))['default']
InfoPopover: (typeof import('./../components/common/InfoPopover.vue'))['default']
MsgInput: (typeof import('./../components/rightBox/MsgInput.vue'))['default']
NaiveProvider: (typeof import('./../components/common/NaiveProvider.vue'))['default']
NAlert: (typeof import('naive-ui'))['NAlert']
NAvatar: (typeof import('naive-ui'))['NAvatar']
NAvatarGroup: (typeof import('naive-ui'))['NAvatarGroup']
NBadge: (typeof import('naive-ui'))['NBadge']
NButton: (typeof import('naive-ui'))['NButton']
NButtonGroup: (typeof import('naive-ui'))['NButtonGroup']
NCheckbox: (typeof import('naive-ui'))['NCheckbox']
NCollapse: (typeof import('naive-ui'))['NCollapse']
NCollapseItem: (typeof import('naive-ui'))['NCollapseItem']
NConfigProvider: (typeof import('naive-ui'))['NConfigProvider']
NDialogProvider: (typeof import('naive-ui'))['NDialogProvider']
NDropdown: (typeof import('naive-ui'))['NDropdown']
NEllipsis: (typeof import('naive-ui'))['NEllipsis']
NFlex: (typeof import('naive-ui'))['NFlex']
NIcon: (typeof import('naive-ui'))['NIcon']
NIconWrapper: (typeof import('naive-ui'))['NIconWrapper']
NImage: (typeof import('naive-ui'))['NImage']
NImageGroup: (typeof import('naive-ui'))['NImageGroup']
NInput: (typeof import('naive-ui'))['NInput']
NLoadingBarProvider: (typeof import('naive-ui'))['NLoadingBarProvider']
NMessageProvider: (typeof import('naive-ui'))['NMessageProvider']
NModal: (typeof import('naive-ui'))['NModal']
NModalProvider: (typeof import('naive-ui'))['NModalProvider']
NNotificationProvider: (typeof import('naive-ui'))['NNotificationProvider']
NPopover: (typeof import('naive-ui'))['NPopover']
NPopselect: (typeof import('naive-ui'))['NPopselect']
NQrCode: (typeof import('naive-ui'))['NQrCode']
NRadio: (typeof import('naive-ui'))['NRadio']
NScrollbar: (typeof import('naive-ui'))['NScrollbar']
NSelect: (typeof import('naive-ui'))['NSelect']
NSkeleton: (typeof import('naive-ui'))['NSkeleton']
NSwitch: (typeof import('naive-ui'))['NSwitch']
NTabPane: (typeof import('naive-ui'))['NTabPane']
NTabs: (typeof import('naive-ui'))['NTabs']
NTooltip: (typeof import('naive-ui'))['NTooltip']
NVirtualList: (typeof import('naive-ui'))['NVirtualList']
RenderMessage: (typeof import('./../components/rightBox/renderMessage/index.vue'))['default']
RouterLink: (typeof import('vue-router'))['RouterLink']
RouterView: (typeof import('vue-router'))['RouterView']
Text: (typeof import('./../components/rightBox/renderMessage/Text.vue'))['default']
}
}

View File

@ -71,4 +71,5 @@ declare interface Window {
$notification: ReturnType<typeof useNotification>
$loadingBar: ReturnType<typeof useLoadingBar>
$dialog: ReturnType<typeof useDialog>
$modal: ReturnType<typeof useModal>
}

View File

@ -39,7 +39,7 @@ declare namespace OPT {
label: string
icon: string
click?: (...args: any[]) => void
}
} | null
/** 详情页选项 */
type Details = {

View File

@ -25,8 +25,11 @@ declare namespace STO {
password?: string
name: string
avatar: string
uid: string
uid: number
token: string
}
/* 用户徽章列表 */
badgeList: T
}
/** 聊天设置 */
chat: {

View File

@ -1,4 +1,5 @@
import mitt from 'mitt'
import type { Emitter } from 'mitt'
const Mitt = mitt()
const Mitt: Emitter<any> = mitt()
export default Mitt

104
src/utils/ComputedTime.ts Normal file
View File

@ -0,0 +1,104 @@
import dayjs from 'dayjs'
import weekday from 'dayjs/plugin/weekday'
import 'dayjs/locale/zh-cn' // 导入中文语言包
import type { Dayjs, OpUnitType, ConfigType } from 'dayjs'
import type { MessageType } from '@/services/types'
// 全局使用语言包
dayjs.locale('zh-cn')
// 设置一周起始位周一
dayjs.extend(weekday)
// 5 分钟 5 * 60 * 1000;
const intervalTime = 300000
// 计数上限 20 条,到达 20 重置
const computedCountMax = 20
// 计数
let computedCount = 0
// 时间格式化为相对文本,仿微信风格
const timeToStr = (time: number) => {
const sendTime = dayjs(time)
// 计算今天和消息的发送时间间隔多少天
const gapDay = dayjs().endOf('day').diff(sendTime, 'day')
// 消息与今天是否 7 天及以上了
const isLastWeek = gapDay >= 7
// 今天显示时分, 昨天的显示 `昨天 时分`, 今天往前一周内,显示`周几 时分` 再前面显示日期 `年月日 时分`
return gapDay < 2
? `${gapDay === 1 ? '昨天 ' : ''}${sendTime.format('HH:mm')}`
: isLastWeek
? sendTime.format('YYYY-MM-DD HH:mm')
: dayjs(sendTime).format('dddd HH:mm')
}
// 超过5分钟或者超过20条消息就添加展示时间
const checkTimeInterval = (cur: MessageType, pre: MessageType) => {
// 如果有一个超过 5 分钟了或者计数达到 20 条了
if ((pre && cur.message.sendTime - pre.message.sendTime > intervalTime) || computedCount >= computedCountMax) {
// 重置计数
computedCount = 0
// 返回时间标记
return { ...cur, timeBlock: timeToStr(cur.message.sendTime) }
} else {
// 时间间隔很短的就累计计数
computedCount += 1
return cur
}
}
export const computedTimeBlock = (list: MessageType[], needFirst = true) => {
if (!list || list.length === 0) return []
// 是否需要保留 传入 list 第一个,如果是接口拉回来的消息列表就要保留,如果接收到新消息,需要拿当前消息列表最后一个拿来做时间间隔计算的话,就不需要保留第一个
const temp = needFirst ? [list[0]] : []
// 跳过第一个
for (let index = 1, len = list.length; index < len; index++) {
const item = list[index]
// 上个聊天记录
const preItem = list[index - 1]
// 超过20分钟或者超过50条评论展示时间
temp.push(checkTimeInterval(item, preItem))
}
return temp
}
/**
*
* @param timestamp
* @param isDetail
* @returns
*/
export const formatTimestamp = (timestamp: number, isDetail = false): string => {
const now: Dayjs = dayjs()
const date: Dayjs = dayjs(timestamp)
// 计算今天和消息的发送时间间隔多少天
const gapDay = dayjs().endOf('day').diff(date, 'day')
// 消息与今天是否 7 天及以上了
const isLastWeek = gapDay >= 7
if (now.isSame(date, 'day')) {
return date.format(`${isDetail ? 'HH:mm:ss' : 'HH:mm'}`)
} else if (now.diff(date, 'year') >= 1) {
return date.format(`${isDetail ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD'}`)
} else {
if (isDetail) return date.format('MM-DD HH:mm:ss')
return gapDay === 1 ? '昨天' : isLastWeek ? date.format('MM-DD') : dayjs(date).format('dddd')
}
}
/**
*
* @param {ConfigType} time
* @param {OpUnitType} unit
* @param {number} diff
* @returns boolean now
*/
export const isDiffNow = ({ time, unit, diff }: { unit: OpUnitType; time: ConfigType; diff: number }): boolean => {
return dayjs().diff(dayjs(time), unit) > diff
}
/**
* 10
* @param {ConfigType} time
* @returns boolean now
*/
export const isDiffNow10Min = (time: ConfigType): boolean => {
return isDiffNow({ time, unit: 'minute', diff: 10 })
}

3
src/utils/InitWorker.ts Normal file
View File

@ -0,0 +1,3 @@
export const worker: Worker = new Worker(new URL('./Worker.ts', import.meta.url), {
type: 'module'
})

View File

@ -0,0 +1,50 @@
import Mitt from '@/utils/Bus.ts'
import apis from '@/services/apis'
import type { MsgReadUnReadCountType } from '@/services/types'
const queue = new Set<number>()
let timer: number | null = null
let request: any = null
const onAddReadCountTask = ({ msgId }: { msgId: number }) => {
queue.add(msgId)
}
const onRemoveReadCountTask = ({ msgId }: { msgId: number }) => {
queue.delete(msgId)
}
const task = () => {
// 10s 了上个请求还未完成就中断掉
request?.abort()
if (queue.size > 0) {
// 开始新请求
request = apis.getMsgReadCount({ params: { msgIds: [...queue] } })
request.send().then((res: MsgReadUnReadCountType[]) => {
const result = new Map<number, MsgReadUnReadCountType>()
res.forEach((item) => result.set(item.msgId, item))
Mitt.emit('onGetReadCount', result)
request = null
})
}
}
export const initListener = () => {
Mitt.on('onAddReadCountTask', onAddReadCountTask)
Mitt.on('onRemoveReadCountTask', onRemoveReadCountTask)
clearQueue()
}
export const clearListener = () => {
Mitt.off('onAddReadCountTask', onAddReadCountTask)
Mitt.off('onRemoveReadCountTask', onRemoveReadCountTask)
timer && clearInterval(timer)
}
export const clearQueue = () => {
queue.clear()
timer && clearInterval(timer)
}
export const readCountQueue = () => {
task()
timer = setInterval(task, 10000) as any
}

View File

@ -0,0 +1,40 @@
import { MsgEnum, RoomTypeEnum } from '@/enums'
import { MSG_REPLY_TEXT_MAP } from '@/common/message'
// 计算展示的回复消息的内容
export const renderReplyContent = (name?: string, type?: MsgEnum, content?: string, roomType?: RoomTypeEnum) => {
switch (type) {
case MsgEnum.SYSTEM:
case MsgEnum.TEXT: {
return roomType === RoomTypeEnum.GROUP ? `${name}:${content}` : content
}
case MsgEnum.IMAGE: {
return roomType === RoomTypeEnum.GROUP
? `${name}:${MSG_REPLY_TEXT_MAP[MsgEnum.IMAGE]}`
: MSG_REPLY_TEXT_MAP[MsgEnum.IMAGE]
}
case MsgEnum.FILE: {
return roomType === RoomTypeEnum.GROUP
? `${name}:${MSG_REPLY_TEXT_MAP[MsgEnum.FILE]}`
: MSG_REPLY_TEXT_MAP[MsgEnum.FILE]
}
case MsgEnum.VOICE: {
return roomType === RoomTypeEnum.GROUP
? `${name}:${MSG_REPLY_TEXT_MAP[MsgEnum.VOICE]}`
: MSG_REPLY_TEXT_MAP[MsgEnum.VOICE]
}
case MsgEnum.VIDEO: {
return roomType === RoomTypeEnum.GROUP
? `${name}:${MSG_REPLY_TEXT_MAP[MsgEnum.VIDEO]}`
: MSG_REPLY_TEXT_MAP[MsgEnum.VIDEO]
}
case MsgEnum.EMOJI: {
return roomType === RoomTypeEnum.GROUP
? `${name}:${MSG_REPLY_TEXT_MAP[MsgEnum.EMOJI]}`
: MSG_REPLY_TEXT_MAP[MsgEnum.EMOJI]
}
default: {
return ''
}
}
}

View File

@ -1,15 +1,131 @@
// 在 Web Worker 中接收消息
self.onmessage = (event) => {
const number = event.data
// 发消息给主进程
import { WsResEnum } from '@/enums'
// 执行一些耗时的操作
const result = calculateSquare(number)
// 将结果发送回主线程
self.postMessage(result)
const postMsg = ({ type, value }: { type: string; value?: object }) => {
self.postMessage(JSON.stringify({ type, value }))
}
// 一些耗时的操作
const calculateSquare = (number: any) => {
return number * number
// ws instance
let connection: WebSocket
// 心跳 timer
let heartTimer: number | null = null
// 重连次数上限
const reconnectCountMax = 5
let reconnectCount = 0
// 重连 timer
let timer: null | number = null
// 重连🔐
let lockReconnect = false
// 重连🔐
let token: null | string = null
// 往 ws 发消息
const connectionSend = (value: object) => {
connection?.send(JSON.stringify(value))
}
// 发送心跳 10s 内发送
const sendHeartPack = () => {
// 10s 检测心跳
heartTimer = setInterval(() => {
// 心跳消息类型 2
connectionSend({ type: 2 })
}, 9900) as any
}
// 清除❤️跳 timer
const clearHeartPackTimer = () => {
if (heartTimer) {
clearInterval(heartTimer)
heartTimer = null
}
}
const onCloseHandler = () => {
clearHeartPackTimer()
// 已经在连接中就不重连了
if (lockReconnect) return
// 标识重连中
lockReconnect = true
// 清除 timer避免任务堆积。
if (timer) {
clearTimeout(timer)
timer = null
}
// 达到重连次数上限
if (reconnectCount >= reconnectCountMax) {
reconnectCount = 0
postMsg({ type: WsResEnum.WS_ERROR, value: { msg: '连接失败,请检查网络或联系管理员' } })
return
}
// 断线重连
timer = setTimeout(() => {
initConnection()
reconnectCount++
// 标识已经开启重连任务
lockReconnect = false
}, 2000) as any
}
// ws 连接 error
const onConnectError = () => {
if (connection?.readyState !== WebSocket.OPEN) {
postMsg({ type: WsResEnum.WS_ERROR, value: { msg: '连接失败,请检查网络或联系管理员' } })
return
}
onCloseHandler()
postMsg({ type: 'error' })
}
// ws 连接 close
const onConnectClose = () => {
onCloseHandler()
token = null
postMsg({ type: 'close' })
}
// ws 连接成功
const onConnectOpen = () => {
postMsg({ type: 'open' })
// 心跳❤️检测
sendHeartPack()
}
// ws 连接 接收到消息
const onConnectMsg = (e: any) => postMsg({ type: 'message', value: e.data })
// 初始化 ws 连接
const initConnection = () => {
connection?.removeEventListener('message', onConnectMsg)
connection?.removeEventListener('open', onConnectOpen)
connection?.removeEventListener('close', onConnectClose)
connection?.removeEventListener('error', onConnectError)
// 建立链接
// 本地配置到 .env 里面修改。生产配置在 .env.production 里面
connection = new WebSocket(`${import.meta.env.VITE_WEBSOCKET_URL}${token ? `?token=${token}` : ''}`)
// 收到消息
connection.addEventListener('message', onConnectMsg)
// 建立链接
connection.addEventListener('open', onConnectOpen)
// 关闭连接
connection.addEventListener('close', onConnectClose)
// 连接错误
connection.addEventListener('error', onConnectError)
}
self.onmessage = (e: MessageEvent<string>) => {
const { type, value } = JSON.parse(e.data)
switch (type) {
case 'initWS': {
reconnectCount = 0
token = value
initConnection()
break
}
case 'message': {
if (connection?.readyState !== 1) return
connectionSend(value)
break
}
}
}

10
src/utils/console.ts Normal file
View File

@ -0,0 +1,10 @@
import pkg from '../../package.json'
/** 控制台打印版本信息 */
export const consolePrint = () => {
console.log(
`%c 🍀 ${pkg.name} ${pkg.version}`,
'font-size:20px;border-left: 4px solid #13987f;background: #cef9ec;font-family: Comic Sans MS, cursive;color:#581845;padding:10px;border-radius:4px;',
`${pkg.author.url}`
)
}

5
src/utils/unique.ts Normal file
View File

@ -0,0 +1,5 @@
import type { UserItem } from '@/services/types'
export const uniqueUserList = (arr: UserItem[]) => {
return Object.values(Object.fromEntries(arr.map((item) => [item.uid, item])))
}

56
src/utils/wsType.ts Normal file
View File

@ -0,0 +1,56 @@
import type { UserInfoType, UserItem } from '@/services/types'
// 1.登录返回二维码 2.用户扫描成功等待授权 3.用户登录成功返回用户信息 4.收到消息 5.上下线推送 6.前端token失效
export enum WsResponseMessageType {
/** 1.登录返回二维码 */
LoginQrCode = 1,
/** 2.用户扫描成功等待授权 */
WaitingAuthorize,
/** 3.用户登录成功返回用户信息 */
LoginSuccess,
/** 4.收到消息 */
ReceiveMessage,
/** 5.上下线推送 */
OnOffLine,
/** 6.前端token失效 */
TokenExpired,
/** 7.禁用的用户 */
InValidUser,
/** 8.点赞、倒赞更新通知 */
WSMsgMarkItem,
/** 消息撤回 */
WSMsgRecall,
/** 新好友申请 */
RequestNewFriend,
/** 新好友会话 */
NewFriendSession
}
/**
* ws 1.2 3
*/
export enum WsRequestMsgType {
/** 1.请求登录二维码 */
RequestLoginQrCode = 1,
/** 2心跳检测 */
HeartBeatDetection,
/** 3用户认证 */
Authorization
}
export type WsReqMsgContentType = {
type: WsRequestMsgType
data?: Record<string, unknown>
}
export type LoginInitResType = { loginUrl: string }
export type LoginSuccessResType = Pick<UserInfoType, 'avatar' | 'name' | 'uid'> & {
/** 用户的登录凭证,每次请求携带 */
token: string
}
export type OnStatusChangeType = {
changeList: UserItem[]
onlineNum: number
totalNum: number
}

View File

@ -42,7 +42,7 @@
</n-flex>
<n-flex v-else vertical :size="6" class="tray">
<n-flex @click="exit(0)" align="center" :size="10" class="p-[8px_6px] rounded-4px hover:bg-[--tray-hover-e]">
<n-flex @click="handleExit" align="center" :size="10" class="p-[8px_6px] rounded-4px hover:bg-[--tray-hover-e]">
<span>退出</span>
</n-flex>
</n-flex>
@ -55,15 +55,25 @@ import { statusItem } from './home-window/onlineStatus/config.ts'
import { onlineStatus } from '@/stores/onlineStatus.ts'
import { appWindow } from '@tauri-apps/api/window'
import { listen } from '@tauri-apps/api/event'
import { useWsLoginStore } from '@/stores/ws.ts'
const { checkWinExist, createWebviewWindow, resizeWindow } = useWindow()
const OLStatusStore = onlineStatus()
const isLoginWin = ref(true)
const loginStore = useWsLoginStore()
const loginQrCode = computed(() => loginStore.loginQrCode)
const division = () => {
return <div class={'h-1px bg-[--line-color] w-full'}></div>
}
const handleExit = () => {
exit(0)
if (loginQrCode.value) {
localStorage.removeItem('wsLogin')
}
}
const toggleStatus = (url: string, title: string) => {
OLStatusStore.setOnlineStatus(url, title)
appWindow.hide()

View File

@ -5,82 +5,69 @@
<n-scrollbar style="max-height: calc(100vh - 126px)">
<n-collapse :display-directive="'show'">
<ContextMenu @contextmenu="showMenu($event)" @select="handleSelect($event.label)" :menu="menuList">
<n-collapse-item title="我的设备" name="1">
<n-collapse-item title="我的好友" name="1">
<template #header-extra>
<p class="text-(10px #707070)">1/1</p>
</template>
<div>可以</div>
</n-collapse-item>
<n-collapse-item title="特别关心" name="2">
<template #header-extra>
<p class="text-(10px #707070)">1/1</p>
<span class="text-(10px #707070)">1/1</span>
</template>
<!-- 用户框 多套一层div来移除默认的右键事件然后覆盖掉因为margin空隙而导致右键可用 -->
<div @contextmenu.stop="$event.preventDefault()">
<div
<n-flex
v-slide
@click="handleClick(item.key, 2)"
:class="{ active: activeItem === item.key }"
:size="10"
@click="handleClick(item.uid, RoomTypeEnum.SINGLE)"
:class="{ active: activeItem === item.uid }"
class="user-box w-full h-75px mb-5px"
v-for="item in friendsList"
:key="item.key">
<div class="flex items-center h-full pl-6px pr-8px gap-10px">
<img class="size-44px rounded-50% bg-#fff border-(1px solid #f1f1f1)" :src="item.avatar" alt="" />
v-for="item in contactStore.contactsList"
:key="item.uid">
<n-flex v-slide align="center" :size="10" class="h-75px pl-6px pr-8px flex-1 truncate">
<n-avatar
round
bordered
:color="'#fff'"
:size="44"
:src="useUserInfo(item.uid).value.avatar"
fallback-src="/logo.png" />
<div class="h-38px flex flex-1 flex-col justify-between">
<div class="text-14px flex-y-center gap-4px">
{{ item.accountName }}
</div>
<n-flex vertical justify="space-between" class="h-fit flex-1 truncate">
<span class="text-14px leading-tight flex-1 truncate">{{
useUserInfo(item.uid).value.name
}}</span>
<div
class="text w-155px h-14px text-12px flex-y-center gap-4px"
style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis">
<p class="text-12px">[今日天气]</p>
<p>说的很经典哈萨克的哈萨克看到贺卡上</p>
</div>
</div>
</div>
</div>
<span class="text leading-tight text-12px flex-1 truncate">
[今日天气] 说的很经典哈萨克的哈萨克看到贺卡上
</span>
</n-flex>
</n-flex>
</n-flex>
</div>
</n-collapse-item>
<n-collapse-item title="默认分组" name="3">
<template #header-extra>
<p class="text-(10px #707070)">1/1</p>
</template>
<div>123</div>
</n-collapse-item>
</ContextMenu>
</n-collapse>
</n-scrollbar>
</n-tab-pane>
<n-tab-pane name="2" tab="群聊">
<div
@click="handleClick(item.key, 1)"
:class="{ active: activeItem === item.key }"
class="w-full h-75px mb-5px cursor-pointer"
v-for="item in groupChatList"
:key="item.key">
<!-- 消息框使用v-slide自定义指令来自动抉择右键菜单位置 -->
<div v-slide class="flex items-center h-full pl-6px pr-8px gap-10px">
<img class="size-44px rounded-50% bg-#fff border-(1px solid #f1f1f1)" :src="item.avatar" alt="" />
<!-- <n-tab-pane name="2" tab="群聊">-->
<!-- <div-->
<!-- @click="handleClick(item.key, RoomTypeEnum.GROUP)"-->
<!-- :class="{ active: activeItem === item.key }"-->
<!-- class="w-full h-75px mb-5px"-->
<!-- v-for="item in groupChatList"-->
<!-- :key="item.key">-->
<!-- <n-flex v-slide align="center" :size="10" class="h-75px pl-6px pr-8px flex-1 truncate">-->
<!-- <n-avatar round bordered :color="'#fff'" :size="44" :src="item.avatar" fallback-src="/logo.png" />-->
<div class="h-38px flex flex-1 flex-col justify-center">
<div class="flex-between-center">
<span class="text-14px">{{ item.accountName }}</span>
</div>
</div>
</div>
</div>
</n-tab-pane>
<!-- <span class="text-14px leading-tight flex-1 truncate">{{ item.accountName }}</span>-->
<!-- </n-flex>-->
<!-- </div>-->
<!-- </n-tab-pane>-->
</n-tabs>
</n-scrollbar>
</template>
<script setup lang="ts">
import Mitt from '@/utils/Bus.ts'
import { MockList } from '@/mock/index.ts'
import { MittEnum } from '@/enums'
import { MittEnum, RoomTypeEnum } from '@/enums'
import { useContactStore } from '@/stores/contacts.ts'
import { useUserInfo } from '@/hooks/useCached.ts'
const menuList = ref([
{ label: '添加分组', icon: 'plus' },
@ -91,10 +78,7 @@ const menuList = ref([
const activeItem = ref(0)
const detailsShow = ref(false)
const shrinkStatus = ref(false)
const friendsList = ref(MockList.value.filter((item) => item.type === 2))
const groupChatList = ref(MockList.value.filter((item) => item.type === 1))
const contactStore = useContactStore()
/** 监听独立窗口关闭事件 */
watchEffect(() => {
Mitt.on(MittEnum.SHRINK_WINDOW, async (event) => {
@ -106,11 +90,10 @@ const handleClick = (index: number, type: number) => {
detailsShow.value = true
activeItem.value = index
const data = {
type: type,
data:
type === 1
? groupChatList.value.filter((item) => item.key === index)
: friendsList.value.filter((item) => item.key === index),
context: {
type: type,
uid: index
},
detailsShow: detailsShow.value
}
Mitt.emit(MittEnum.DETAILS_SHOW, data)

View File

@ -1,40 +1,50 @@
<template>
<!-- 消息列表 // TODO 使 (nyh -> 2024-03-28 06:01:00) -->
<n-scrollbar ref="scrollbar" v-if="MockList.length > 0" style="max-height: calc(100vh - 70px)">
<n-scrollbar ref="scrollbar" style="max-height: calc(100vh - 70px)">
<!-- 可拖拽排序组件 -->
<VueDraggable target=".sort-target" :animation="150" v-model="MockList" class="p-[4px_10px_0px_8px]">
<VueDraggable v-model="sessionList" :animation="150" class="p-[4px_10px_0px_8px]" target=".sort-target">
<!-- 右键菜单组件 -->
<TransitionGroup type="transition" tag="div" name="fade" class="sort-target">
<TransitionGroup class="sort-target" name="fade" tag="div" type="transition">
<ContextMenu
@select="$event.click(item)"
@click="handleMsgClick(item)"
@dblclick="handleMsgDblclick(item)"
:data-key="item.key"
v-for="item in sessionList"
:key="item.roomId"
:class="{ active: currentSession === item.roomId }"
:data-key="item.roomId"
:menu="menuList"
:special-menu="specialMenuList"
:class="{ active: activeIndex === item.key }"
class="msg-box w-full h-75px mb-5px"
v-for="item in MockList"
:key="item.key">
@click="onSelectSelectSession(item, item.type)"
@dblclick="handleMsgDblclick(item)"
@select="$event.click(item)">
<!-- 消息框使用v-slide自定义指令来自动抉择右键菜单位置 -->
<n-flex v-slide align="center" :size="10" class="h-75px pl-6px pr-8px">
<n-avatar round bordered :color="'#fff'" :size="44" :src="item.avatar" fallback-src="/logo.png" alt="" />
<n-flex v-slide :size="10" align="center" class="h-75px pl-6px pr-8px flex-1">
<n-avatar :color="'#fff'" :size="44" :src="item.avatar" bordered fallback-src="/logo.png" round />
<n-flex vertical justify="space-between" class="h-38px flex-1">
<n-flex align="center" justify="space-between">
<span class="text-14px">{{ item.accountName }}</span>
<span class="text text-10px">昨天</span>
<n-flex class="h-fit flex-1 truncate" justify="space-between" vertical>
<n-flex :size="4" align="center" class="flex-1 truncate" justify="space-between">
<n-flex :size="0" align="center" class="leading-tight flex-1 truncate">
<span class="text-14px leading-tight flex-1 truncate">{{ item.name }}</span>
<n-popover trigger="hover" v-if="item.hot_Flag === IsAllUserEnum.Yes">
<template #trigger>
<svg
:class="[currentSession === item.roomId ? 'color-#33ceab' : 'color-#13987f']"
class="size-20px select-none outline-none cursor-pointer">
<use href="#auth"></use>
</svg>
</template>
<span>官方群聊认证</span>
</n-popover>
</n-flex>
<span class="text text-10px w-fit truncate text-right">{{ item.lastMsgTime }}</span>
</n-flex>
<n-flex align="center" justify="space-between">
<span
class="text w-135px text-12px"
style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis">
说的很经典哈萨克的哈萨克看到贺卡上
<span class="text flex-1 leading-tight text-12px truncate">
{{ item.lastMsg.replace(':', '') }}
</span>
<!-- 消息提示 -->
<n-badge :value="msgTotal" :max="99" />
<n-badge :max="99" :value="item.unreadCount" />
</n-flex>
</n-flex>
</n-flex>
@ -42,31 +52,66 @@
</TransitionGroup>
</VueDraggable>
</n-scrollbar>
<!-- 暂无消息 -->
<n-flex id="image-no-data" v-else :size="20" align="center" vertical>
<n-image
width="120px"
height="110px"
src="src/assets/img/no_data.svg"
lazy
preview-disabled
:intersection-observer-options="{
root: '#image-no-data'
}" />
<span>暂无消息</span>
</n-flex>
</template>
<script setup lang="ts">
<script lang="ts" setup>
import Mitt from '@/utils/Bus.ts'
import { VueDraggable } from 'vue-draggable-plus'
import { MockList } from '@/mock'
import { useMessage } from '@/hooks/useMessage.ts'
import { MittEnum } from '@/enums'
import { MittEnum, MsgEnum, RoomTypeEnum } from '@/enums'
import { IsAllUserEnum, SessionItem } from '@/services/types.ts'
import { formatTimestamp } from '@/utils/ComputedTime.ts'
import { useChatStore } from '@/stores/chat.ts'
import { useGlobalStore } from '@/stores/global.ts'
import { useUserInfo } from '@/hooks/useCached.ts'
import { renderReplyContent } from '@/utils/RenderReplyContent.ts'
import { useCommon } from '@/hooks/useCommon.ts'
const msgTotal = ref(0)
const chatStore = useChatStore()
const globalStore = useGlobalStore()
const { userUid } = useCommon()
const scrollbar = ref()
const { activeIndex, handleMsgClick, menuList, specialMenuList, handleMsgDblclick } = useMessage()
const { handleMsgClick, activeIndex, menuList, specialMenuList, handleMsgDblclick } = useMessage()
const sessionList = computed(() =>
chatStore.sessionList.map((item) => {
//
const lastMsg = Array.from(chatStore.messageMap.get(item.roomId)?.values() || [])?.slice(-1)?.[0]
let LastUserMsg = ''
if (lastMsg) {
const lastMsgUserName = useUserInfo(lastMsg.fromUser.uid)
LastUserMsg =
lastMsg.message?.type === MsgEnum.RECALL
? item.type === RoomTypeEnum.GROUP
? `${lastMsgUserName.value.name}:撤回了一条消息`
: lastMsg.fromUser.uid === userUid.value
? '你撤回了一条消息'
: '对方撤回了一条消息'
: (renderReplyContent(
lastMsgUserName.value.name,
lastMsg.message?.type,
lastMsg.message?.body?.content || lastMsg.message?.body,
item.type
) as string)
}
return {
...item,
lastMsg: LastUserMsg || item.text || '欢迎使用HuLa',
lastMsgTime: formatTimestamp(item?.activeTime)
}
})
)
const msgTotal = computed(() => {
return sessionList.value.reduce((total, item) => total + item.unreadCount, 0)
})
//
const currentSession = computed(() => activeIndex.value)
//
const onSelectSelectSession = (item: SessionItem, roomType: RoomTypeEnum) => {
handleMsgClick(item)
globalStore.currentSession.roomId = item.roomId
globalStore.currentSession.type = roomType
}
watchEffect(() => {
// TODO (nyh -> 2024-03-22 01:05:22)
@ -76,25 +121,30 @@ watchEffect(() => {
}
})
onBeforeMount(() => {
//
chatStore.getSessionList(true)
})
onMounted(() => {
// TODO keydata-key (nyh -> 2024-03-28 18:56:20)
if (activeIndex.value !== -1) {
nextTick(() => {
const activeElement = document.querySelector(`.msg-box[data-key="${activeIndex.value}"]`) as HTMLElement
const rect = activeElement.getBoundingClientRect()
scrollbar.value.scrollTo({
top: rect.top - 75,
behavior: 'smooth'
})
})
}
// if (currentSession.value.roomId !== -1) {
// nextTick(() => {
// const activeElement = document.querySelector(`.msg-box[data-key="${currentSession.value.roomId}"]`) as HTMLElement
// const rect = activeElement.getBoundingClientRect()
// scrollbar.value.scrollTo({
// top: rect.top - 75,
// behavior: 'smooth'
// })
// })
// }
// setInterval(() => {
// msgTotal.value++
// }, 1000)
})
</script>
<style scoped lang="scss">
<style lang="scss" scoped>
@import '@/styles/scss/message';
#image-no-data {
@apply size-full mt-60px text-[--text-color] text-14px;

View File

@ -13,7 +13,7 @@
v-for="(item, index) in titleList"
:key="index">
<div
@click="handleTheme($event, item.code)"
@click="handleTheme(item.code)"
class="size-full rounded-8px cursor-pointer"
:style="activeItem === item.code ? 'border: 2px solid #13987f' : 'border: 2px solid transparent'">
<component :is="item.model" />
@ -89,7 +89,7 @@
<script setup lang="tsx">
import { setting } from '@/stores/setting.ts'
import { storeToRefs } from 'pinia'
import { CloseBxEnum, ThemeEnum } from '@/enums'
import { CloseBxEnum } from '@/enums'
import { titleList } from './model.tsx'
import { sendOptions } from './config.ts'
@ -98,40 +98,13 @@ const { themes, tips, escClose, chat } = storeToRefs(settingStore)
const activeItem = ref<string>(themes.value.pattern)
/** 切换主题 */
const handleTheme = async (event: MouseEvent, code: string) => {
const handleTheme = (code: string) => {
if (code === themes.value.pattern) return
const x = event.clientX
const y = event.clientY
const endRadius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y))
let isDark: boolean
settingStore.toggleTheme(code)
/**判断当前浏览器是否支持startViewTransition API*/
if (document.startViewTransition) {
const transition = document.startViewTransition(() => {
isDark = code.includes(ThemeEnum.DARK)
})
// TODO (nyh -> 2024-02-12 23:07:54)
transition.ready.then(() => {
const clipPath = [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`]
document.documentElement.animate(
{
clipPath: isDark ? [...clipPath].reverse() : clipPath
},
{
duration: 500,
easing: 'ease-in',
pseudoElement: isDark ? '::view-transition-old(root)' : '::view-transition-new(root)'
}
)
})
}
}
</script>
<style scoped lang="scss">
@import '@/styles/scss/toggle-theme';
.item {
@apply bg-[--bg-setting-item] rounded-12px size-full p-12px box-border;
}

View File

@ -9,7 +9,10 @@
<n-flex vertical :size="25" v-if="!isAutoLogin">
<!-- 头像 -->
<n-flex justify="center" class="w-full mt-35px">
<img class="w-80px h-80px rounded-50% bg-#fff border-(2px solid #fff)" :src="avatarRef || '/logo.png'" alt="" />
<img
class="w-80px h-80px rounded-50% bg-#fff border-(2px solid #fff)"
:src="info.avatar || '/logo.png'"
alt="" />
</n-flex>
<!-- 登录菜单 -->
@ -19,7 +22,7 @@
size="large"
maxlength="16"
minlength="6"
v-model:value="accountRef"
v-model:value="info.account"
type="text"
:placeholder="accountPH"
@focus="accountPH = ''"
@ -62,7 +65,7 @@
maxlength="16"
minlength="6"
size="large"
v-model:value="passwordRef"
v-model:value="info.password"
type="password"
:placeholder="passwordPH"
@focus="passwordPH = ''"
@ -100,10 +103,12 @@
<n-flex :size="30" vertical>
<!-- 头像 -->
<n-flex justify="center">
<img
class="w-110px h-110px rounded-50% bg-#fff border-(2px solid #fff)"
:src="login.accountInfo.avatar || '/logo.png'"
alt="" />
<n-avatar
round
:size="110"
:color="'#fff'"
class="border-(2px solid #fff)"
:src="login.accountInfo.avatar || '/logo.png'" />
</n-flex>
<n-flex justify="center">
@ -116,18 +121,19 @@
:loading="loading"
:disabled="loginDisabled"
class="w-200px mt-12px mb-40px"
@click="loginWin"
@click="autoLogin"
color="#13987f">
{{ loginText }}
</n-button>
</n-flex>
</n-flex>
<!-- 部操作栏 -->
<n-flex justify="center" class="text-14px">
<!-- 部操作栏 -->
<n-flex justify="center" class="text-14px" id="bottomBar">
<div class="color-#13987f cursor-pointer" @click="router.push('/qrCode')">扫码登录</div>
<div class="w-1px h-14px bg-#ccc"></div>
<n-popover trigger="click" :show-checkmark="false" :show-arrow="false">
<div v-if="isAutoLogin" class="color-#13987f cursor-pointer">移除账号</div>
<n-popover v-else trigger="click" :show-checkmark="false" :show-arrow="false">
<template #trigger>
<div class="color-#13987f cursor-pointer">更多选项</div>
</template>
@ -151,10 +157,17 @@ import { useLogin } from '@/hooks/useLogin.ts'
const settingStore = setting()
const { login } = storeToRefs(settingStore)
const accountRef = ref()
const passwordRef = ref()
const avatarRef = ref()
const nameRef = ref()
/** 账号信息 */
const info = ref({
account: '',
password: '',
avatar: '',
name: '',
uid: 0
})
/** 是否中断登录 */
const interruptLogin = ref(false)
/** 协议 */
const protocol = ref()
const loginDisabled = ref(false)
const loading = ref(false)
@ -168,28 +181,32 @@ const accountOption = ref<STO.Setting['login']['accountInfo'][]>([
password: '123456',
name: '超级GG帮',
avatar: 'https://picsum.photos/140?1',
uid: '123456'
uid: 123456,
token: 'test'
},
{
account: 'hula1',
password: '123456',
name: '二狗子',
avatar: 'https://picsum.photos/140?2',
uid: '123456'
uid: 123456,
token: 'test'
},
{
account: 'hula2',
password: '123456',
name: '李山离',
avatar: 'https://picsum.photos/140?3',
uid: '123456'
uid: 123456,
token: 'test'
},
{
account: 'hula3',
password: '123456',
name: '牛什么呢',
avatar: 'https://picsum.photos/140?4',
uid: '123456'
uid: 123456,
token: 'test'
}
])
const accountPH = ref('输入HuLa账号')
@ -199,10 +216,13 @@ const loginText = ref('登录')
const { createWebviewWindow } = useWindow()
watchEffect(() => {
loginDisabled.value = !(accountRef.value && passwordRef.value && protocol.value)
loginDisabled.value = !(info.value.account && info.value.password && protocol.value)
//
if (!accountRef.value) {
avatarRef.value = '/logo.png'
if (!info.value.account) {
info.value.avatar = '/logo.png'
}
if (interruptLogin.value) {
loginDisabled.value = false
}
})
@ -217,9 +237,9 @@ const delAccount = (index: number) => {
if (lengthBeforeDelete === 1 && accountOption.value.length === 0) {
arrowStatus.value = false
}
accountRef.value = null
passwordRef.value = null
avatarRef.value = '/logo.png'
info.value.account = ''
info.value.password = ''
info.value.avatar = '/logo.png'
}
/**
@ -227,39 +247,57 @@ const delAccount = (index: number) => {
* @param item 账户信息
* */
const giveAccount = (item: STO.Setting['login']['accountInfo']) => {
const { account, password, avatar, name } = item
accountRef.value = account
passwordRef.value = password
avatarRef.value = avatar
nameRef.value = name
const { account, password, avatar, name, uid } = item
info.value.account = account || ''
info.value.password = password || ''
info.value.avatar = avatar
info.value.name = name
info.value.uid = uid
arrowStatus.value = false
}
/**登录后创建主页窗口*/
const loginWin = () => {
if (interruptLogin.value) return
loading.value = true
delay(async () => {
await createWebviewWindow('HuLa', 'home', 960, 720, 'login', false, true)
loading.value = false
if (!login.value.autoLogin || login.value.accountInfo.password === '') {
settingStore.setAccountInfo({
account: accountRef.value,
password: passwordRef.value,
avatar: avatarRef.value,
name: nameRef.value,
uid: '123456'
account: info.value.account,
password: info.value.password,
avatar: info.value.avatar,
name: info.value.name,
uid: info.value.uid,
token: 'test'
})
await setLoginState()
}
}, 1000)
}
/**监听是否点击了除了下拉框外的其他地方*/
const handleClickOutside = (event: MouseEvent) => {
/** 自动登录 */
const autoLogin = () => {
interruptLogin.value = false
isAutoLogin.value = true
// TODO (nyh -> 2024-03-16 12:06:59)
loginText.value = '网络连接中'
delay(async () => {
loginWin()
loginText.value = '登录'
await setLoginState()
}, 1000)
}
const closeMenu = (event: MouseEvent) => {
const target = event.target as Element
if (!target.matches('.account-box, .account-box *, .down')) {
arrowStatus.value = false
}
if (target.matches('#bottomBar *') && isAutoLogin.value) {
interruptLogin.value = true
}
}
onMounted(async () => {
@ -267,20 +305,13 @@ onMounted(async () => {
console.error('设置无状态图标失败:', error)
})
if (login.value.autoLogin && login.value.accountInfo.password !== '') {
isAutoLogin.value = true
// TODO (nyh -> 2024-03-16 12:06:59)
loginText.value = '网络连接中'
delay(async () => {
loginWin()
loginText.value = '登录'
await setLoginState()
}, 1000)
autoLogin()
}
window.addEventListener('click', handleClickOutside, true)
window.addEventListener('click', closeMenu, true)
})
onUnmounted(() => {
window.removeEventListener('click', handleClickOutside, true)
window.removeEventListener('click', closeMenu, true)
})
</script>

View File

@ -14,18 +14,19 @@
v-else
:size="180"
class="rounded-12px relative"
:class="{ blur: scanSuccess }"
:class="{ blur: scanStatus.show }"
:value="QRCode"
icon-src="/logo.png"
error-correction-level="H" />
<!-- 二维码状态 -->
<n-flex
v-if="scanSuccess"
v-if="scanStatus.show"
vertical
:size="12"
align="center"
class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2">
<svg class="size-42px"><use href="#success"></use></svg>
<span class="text-(16px #e3e3e3)">扫码成功</span>
<svg class="size-42px animate-pulse"><use :href="`#${scanStatus.icon}`"></use></svg>
<span class="text-(16px #e3e3e3)">{{ scanStatus.text }}</span>
</n-flex>
</n-flex>
@ -33,7 +34,7 @@
<!-- 顶部操作栏 -->
<n-flex justify="center" class="text-14px mt-48px">
<div class="color-#13987f cursor-pointer" @click="toLogin">账密登录</div>
<div class="color-#13987f cursor-pointer" @click="router.push('/login')">账密登录</div>
<div class="w-1px h-14px bg-#ccc"></div>
<div class="color-#13987f cursor-pointer">注册账号</div>
</n-flex>
@ -43,48 +44,89 @@
import router from '@/router'
import { delay } from 'lodash-es'
import { lightTheme } from 'naive-ui'
import { initWebSocket, sendToServer } from '@/services/webSocket.ts'
import { WsReqEnum, WsResEnum } from '@/enums'
import { WsResEnum } from '@/enums'
import Mitt from '@/utils/Bus.ts'
import { setting } from '@/stores/setting.ts'
import { useLogin } from '@/hooks/useLogin.ts'
import { useWindow } from '@/hooks/useWindow.ts'
import { LoginStatus, useWsLoginStore } from '@/stores/ws.ts'
const settingStore = setting()
const loginStore = useWsLoginStore()
/** 获取登录二维码 */
const loginQrCode = computed(() => loginStore.loginQrCode)
/** 登录状态 */
const loginStatus = computed(() => loginStore.loginStatus)
const { setLoginState } = useLogin()
const { createWebviewWindow } = useWindow()
const loading = ref(true)
const loadText = ref('加载中...')
const QRCode = ref()
const scanSuccess = ref(false)
const scanStatus = ref<{
status: 'error' | 'success'
icon: 'cloudError' | 'success'
text: string
show: boolean
}>({ status: 'success', icon: 'success', text: '扫码成功', show: false })
const toLogin = () => {
router.push('/login')
watchEffect(() => {
//
if (loginStatus.value === LoginStatus.Waiting) {
loadText.value = '等待授权...'
}
})
/** 处理二维码显示和刷新 */
const handleQRCodeLogin = () => {
QRCode.value = loginQrCode.value
loading.value = false
loadText.value = '请使用微信扫码登录'
}
/** 处理登录成功 */
const handleLoginSuccess = async (e: any) => {
scanStatus.value.show = true
loadText.value = '登录中...'
delay(async () => {
await createWebviewWindow('HuLa', 'home', 960, 720, 'login', false, true)
settingStore.setAccountInfo({
avatar: e.avatar,
name: e.name,
uid: e.uid,
token: e.token
})
await setLoginState()
}, 1000)
}
/** 处理失败场景 */
const handleError = (e: any) => {
loading.value = false
scanStatus.value = {
status: 'error',
icon: 'cloudError',
text: e,
show: true
}
loadText.value = '请稍后再试'
}
// TODO (nyh -> 2024-01-27 00:37:18)
onMounted(() => {
initWebSocket()
Mitt.on(WsResEnum.QRCODE_LOGIN, (e: any) => {
QRCode.value = e.data.loginUrl
loading.value = false
loadText.value = '请使用微信扫码登录'
if (!localStorage.getItem('wsLogin')) {
loginStore.getLoginQrCode()
} else {
handleQRCodeLogin()
}
Mitt.on(WsResEnum.QRCODE_LOGIN, () => {
handleQRCodeLogin()
})
Mitt.on(WsResEnum.LOGIN_SUCCESS, (e: any) => {
scanSuccess.value = true
loadText.value = '登录中...'
delay(async () => {
await createWebviewWindow('HuLa', 'home', 960, 720, 'login', false, true)
settingStore.setAccountInfo({
avatar: e.data.avatar,
name: e.data.name,
uid: e.data.uid
})
await setLoginState()
}, 1000)
handleLoginSuccess(e)
})
Mitt.on(WsResEnum.WS_ERROR, (e: any) => {
handleError(e.msg)
})
delay(() => {
sendToServer({ type: WsReqEnum.LOGIN })
}, 1000)
})
</script>

View File

@ -25,7 +25,8 @@
"paths": {
"~/*": ["./*"],
"@/*": ["./src/*"]
}
},
"allowSyntheticDefaultImports": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]

View File

@ -31,6 +31,8 @@ export default defineConfig({
'absolute-rt': 'absolute right-0 top-0',
'absolute-rb': 'absolute right-0 bottom-0',
'absolute-center': 'absolute-lt flex-center size-full',
'absolute-x-center': 'absolute-lt flex-x-center size-full',
'absolute-y-center': 'absolute-lt flex-y-center size-full',
'fixed-lt': 'fixed left-0 top-0',
'fixed-lb': 'fixed left-0 bottom-0',
'fixed-rt': 'fixed right-0 top-0',

View File

@ -7,13 +7,13 @@ import { getRootPath, getSrcPath } from './build/config/getPath'
import vueJsx from '@vitejs/plugin-vue-jsx'
import unocss from '@unocss/vite'
import terser from '@rollup/plugin-terser'
import { atStartup } from './build/config/console'
// https://vitejs.dev/config/
/**! 不需要优化前端打包(如开启gzip) */
export default defineConfig(({ mode }: ConfigEnv) => {
// 获取当前环境的配置,如何设置第三个参数则加载所有变量而不是以“VITE_”前缀的变量
const config = loadEnv(mode, '/')
console.log(config)
const config = loadEnv(mode, process.cwd())
return {
resolve: {
alias: {
@ -35,6 +35,8 @@ export default defineConfig(({ mode }: ConfigEnv) => {
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'true'
},
plugins: [
/**! 启动时候打印项目信息(不需要可关闭) */
atStartup(config, mode),
/**
* !
* defineProps解构语法
@ -43,7 +45,7 @@ export default defineConfig(({ mode }: ConfigEnv) => {
vueJsx(), // 开启jsx功能
unocss(), // 开启unocss
AutoImport({
imports: ['vue', { 'naive-ui': ['useDialog', 'useMessage', 'useNotification', 'useLoadingBar'] }],
imports: ['vue', { 'naive-ui': ['useDialog', 'useMessage', 'useNotification', 'useLoadingBar', 'useModal'] }],
dts: 'src/typings/auto-imports.d.ts'
}),
/**自动导入组件但是不会自动导入jsx和tsx*/
@ -90,6 +92,17 @@ export default defineConfig(({ mode }: ConfigEnv) => {
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
//配置跨域
proxy: {
'/api': {
// “/api” 以及前置字符串会被替换为真正域名
target: config.VITE_SERVICE_URL, // 请求域名
changeOrigin: true, // 是否跨域
rewrite: (path) => path.replace(/^\/api/, '')
}
},
hmr: true, // 热更新
cors: true, // 配置 CORS
host: '0.0.0.0',
port: 6130,
strictPort: true,