Initial Web IM deployment

This commit is contained in:
winchen
2026-06-30 10:13:48 +07:00
commit 1cbc3c4cf0
67 changed files with 55851 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
<template>
<LoginView v-if="!session.isAuthenticated" />
<ChatShell v-else />
</template>
<script setup>
import { onMounted, watch } from 'vue';
import LoginView from './components/LoginView.vue';
import ChatShell from './components/ChatShell.vue';
import { useSessionStore } from './stores/session';
import { useConversationStore } from './stores/conversations';
const session = useSessionStore();
const conversations = useConversationStore();
onMounted(() => {
session.restore();
});
watch(() => session.userId, userId => {
if (userId && session.isAuthenticated) {
conversations.bootstrapDemoData();
return;
}
conversations.clearUserState();
});
</script>
+34
View File
@@ -0,0 +1,34 @@
<template>
<main class="app-shell">
<NavigationRail />
<ConversationList v-if="navigation.section === 'messages'" />
<FavoriteList v-else-if="navigation.section === 'favorites'" />
<ContactList v-else />
<ChatPanel v-if="navigation.section === 'messages'" />
<FavoritePanel v-else-if="navigation.section === 'favorites'" />
<ContactPanel v-else />
</main>
</template>
<script setup>
import { onMounted } from 'vue';
import NavigationRail from './sidebar/NavigationRail.vue';
import ConversationList from './sidebar/ConversationList.vue';
import ContactList from './sidebar/ContactList.vue';
import ChatPanel from './chat/ChatPanel.vue';
import ContactPanel from './contacts/ContactPanel.vue';
import FavoriteList from './favorites/FavoriteList.vue';
import FavoritePanel from './favorites/FavoritePanel.vue';
import { useConversationStore } from '@/stores/conversations';
import { useNavigationStore } from '@/stores/navigation';
import { useContactStore } from '@/stores/contacts';
const conversations = useConversationStore();
const navigation = useNavigationStore();
const contacts = useContactStore();
onMounted(() => {
conversations.refreshRemoteConversations();
contacts.refreshContacts();
});
</script>
+677
View File
@@ -0,0 +1,677 @@
<template>
<main class="login-page">
<section class="login-panel">
<div class="brand-lockup">
<img class="brand-mark" :src="platformLogo" :alt="`${platformName} 图标`" @error="handleBrandLogoError">
<div>
<h1>{{ platformName }}</h1>
<p>连接既有授权 IM 服务的新版 Web 客户端</p>
</div>
</div>
<form v-if="view === 'login'" class="login-form" @submit.prevent="submit">
<div class="login-tabs">
<button type="button" :class="{ active: mode === 'code' }" @click="mode = 'code'">验证码登录</button>
<button type="button" :class="{ active: mode === 'password' }" @click="mode = 'password'">密码登录</button>
</div>
<label>
<span>手机号</span>
<input v-model.trim="mobile" autocomplete="username" inputmode="tel" placeholder="请输入手机号">
</label>
<label v-if="mode === 'password'">
<span>密码</span>
<input v-model="password" autocomplete="current-password" type="password" placeholder="请输入密码">
</label>
<label v-else>
<span>验证码</span>
<div class="code-row">
<input v-model.trim="code" autocomplete="one-time-code" inputmode="numeric" placeholder="本地测试请输入 66666">
<button type="button" class="code-action" :disabled="sendingCode || mobile.length < 5" @click="requestCode">
{{ sendingCode ? '发送中' : '发送验证码' }}
</button>
</div>
</label>
<button class="primary-action" :disabled="session.connecting || !canSubmit">
{{ session.connecting ? '登录中...' : '登录' }}
</button>
<p v-if="session.error" class="form-error">{{ session.error }}</p>
<p v-if="info" class="form-info">{{ info }}</p>
</form>
<form v-else-if="view === 'register'" class="login-form" @submit.prevent="submitRegister">
<div class="login-tabs register-tabs" :style="{ gridTemplateColumns: `repeat(${visibleRegisterMethods.length || 1}, minmax(0, 1fr))` }">
<button
v-for="item in visibleRegisterMethods"
:key="item.key"
type="button"
:class="{ active: registerType === item.key }"
@click="registerType = item.key"
>
{{ item.label }}
</button>
</div>
<label v-if="registerType === 'phone'">
<span class="label-row">
<span>手机号</span>
<span v-if="registerPhoneError" class="field-error-badge">{{ registerPhoneError }}</span>
</span>
<div class="phone-row">
<div class="country-code-field">
<span>+</span>
<input
v-model.trim="registerForm.phoneCountryCode"
aria-label="手机号国码"
autocomplete="tel-country-code"
inputmode="numeric"
maxlength="6"
@input="normalizeRegisterCountryCode"
>
</div>
<input
v-model.trim="registerForm.phone"
:class="{ 'field-input-error': registerPhoneError }"
autocomplete="tel"
inputmode="tel"
placeholder="请输入手机号"
@input="clearRegisterPhoneError"
>
</div>
</label>
<label v-if="registerType === 'phone'">
<span>验证码</span>
<div v-if="mobileRealVerify" class="code-row">
<input v-model.trim="registerForm.code" autocomplete="one-time-code" inputmode="numeric" placeholder="请输入短信验证码">
<button type="button" class="code-action" :disabled="sendingCode || registerForm.phone.length < 5" @click="requestRegisterCode">
{{ sendingCode ? '发送中' : '发送验证码' }}
</button>
</div>
<div v-else class="captcha-row">
<input
v-model.trim="registerForm.code"
:class="phoneCaptchaClass"
autocomplete="off"
inputmode="numeric"
maxlength="6"
placeholder="请输入验证码"
@input="normalizePhoneCaptchaInput"
>
<button v-if="!phoneCaptchaImage" type="button" class="code-action" @click="showPhoneCaptcha">
发送验证码
</button>
<button v-else type="button" class="captcha-button" title="点击更换验证码" @click="showPhoneCaptcha">
<img :src="phoneCaptchaImage" alt="6位数字验证码">
</button>
</div>
</label>
<label v-if="emailRegisterEnabled && registerType === 'email'">
<span>邮箱</span>
<input v-model.trim="registerForm.email" autocomplete="email" inputmode="email" placeholder="请输入邮箱">
</label>
<label v-if="emailRegisterEnabled && registerType === 'email'">
<span>邮箱验证码</span>
<div class="code-row">
<input v-model.trim="registerForm.emailCode" autocomplete="one-time-code" inputmode="numeric" placeholder="请输入验证码">
<button type="button" class="code-action" disabled>发送验证码</button>
</div>
</label>
<label v-if="registerType === 'username'">
<span>用户名</span>
<input v-model.trim="registerForm.username" autocomplete="username" placeholder="请输入用户名">
</label>
<label v-if="registerType === 'username'">
<span>手机号</span>
<div class="phone-row">
<select v-model="registerForm.usernamePhoneCountryCode" aria-label="用户名注册手机号国码">
<option v-for="item in countryCodeOptions" :key="item.value" :value="item.value">{{ item.label }}</option>
</select>
<input v-model.trim="registerForm.usernamePhone" autocomplete="tel" inputmode="tel" placeholder="请输入手机号">
</div>
</label>
<label v-if="registerType === 'username'">
<span>验证码</span>
<div class="captcha-row">
<input
v-model.trim="registerForm.usernameCode"
:class="usernameCaptchaClass"
autocomplete="off"
inputmode="numeric"
maxlength="6"
placeholder="请输入验证码"
@input="normalizeUsernameCaptchaInput"
>
<button v-if="!usernameCaptchaImage" type="button" class="code-action" @click="showUsernameCaptcha">
显示验证码
</button>
<button v-else type="button" class="captcha-button" title="点击更换验证码" @click="showUsernameCaptcha">
<img :src="usernameCaptchaImage" alt="6位数字验证码">
</button>
</div>
</label>
<label v-if="registerType === 'username'">
<span>密码</span>
<input v-model="registerForm.password" autocomplete="new-password" type="password" placeholder="请输入密码">
</label>
<label v-if="registerType === 'username'">
<span>确认密码</span>
<input v-model="registerForm.confirmPassword" autocomplete="new-password" type="password" placeholder="请再次输入密码">
</label>
<button class="primary-action" :disabled="session.connecting || !canRegisterSubmit">
{{ session.connecting ? '注册中...' : '注册' }}
</button>
<p v-if="session.error" class="form-error">{{ session.error }}</p>
<p v-if="info" class="form-info">{{ info }}</p>
</form>
<form v-else class="login-form" @submit.prevent="submitRegisterPassword">
<label>
<span>手机号</span>
<input :value="pendingRegistration.mobile" autocomplete="username" readonly>
</label>
<label>
<span>登录密码</span>
<input v-model="registerPasswordForm.password" autocomplete="new-password" type="password" placeholder="请输入登录密码">
</label>
<label>
<span>确认密码</span>
<input v-model="registerPasswordForm.confirmPassword" autocomplete="new-password" type="password" placeholder="请再次输入密码">
</label>
<button class="primary-action" :disabled="session.connecting || settingRegisterPassword || !canSetRegisterPassword">
{{ settingRegisterPassword || session.connecting ? '保存中...' : '保存并进入' }}
</button>
<p v-if="session.error" class="form-error">{{ session.error }}</p>
<p v-if="info" class="form-info">{{ info }}</p>
</form>
<button v-if="view === 'login'" class="ghost-action" type="button" @click="openRegister">
新用户注册
</button>
<button v-else-if="view === 'register'" class="ghost-action" type="button" @click="backToLogin">
返回登录
</button>
</section>
</main>
</template>
<script setup>
import axios from 'axios';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { appConfig } from '@/config';
import { useSessionStore } from '@/stores/session';
import { imClient } from '@/services/imClient';
const session = useSessionStore();
const view = ref('login');
const mode = ref('code');
const mobile = ref('');
const password = ref('');
const code = ref('66666');
const sendingCode = ref(false);
const info = ref('');
const pendingRegistrationStorageKey = 'qmt-web-im-pending-registration';
const defaultPlatformName = appConfig.appName;
const defaultPlatformLogo = '/img/5IuOieu9PF8MJtp43kD2kCkdLVjVwDp7.ico';
const platformName = ref(defaultPlatformName);
const platformLogo = ref(defaultPlatformLogo);
const registerType = ref('phone');
const emailRegisterEnabled = false;
const registerMethods = ref({ phone: true, email: false, username: false });
const mobileRealVerify = ref(false);
const registerPhoneError = ref('');
const phoneCaptcha = ref('');
const phoneCaptchaImage = ref('');
const usernameCaptcha = ref('');
const usernameCaptchaImage = ref('');
const settingRegisterPassword = ref(false);
const pendingRegistration = reactive({
mobile: '',
result: null,
resetCode: ''
});
const registerForm = reactive({
phoneCountryCode: '86',
phone: '',
code: '',
email: '',
emailCode: '',
username: '',
usernamePhoneCountryCode: '+86',
usernamePhone: '',
usernameCode: '',
password: '',
confirmPassword: ''
});
const registerPasswordForm = reactive({
password: '',
confirmPassword: ''
});
const countryCodeOptions = [
{ value: '+86', label: '+86' },
{ value: '+852', label: '+852' },
{ value: '+853', label: '+853' },
{ value: '+886', label: '+886' },
{ value: '+1', label: '+1' },
{ value: '+44', label: '+44' },
{ value: '+60', label: '+60' },
{ value: '+63', label: '+63' },
{ value: '+65', label: '+65' },
{ value: '+66', label: '+66' },
{ value: '+81', label: '+81' },
{ value: '+82', label: '+82' }
];
const registerMethodOptions = [
{ key: 'phone', label: '手机号注册' },
{ key: 'email', label: '邮箱注册' },
{ key: 'username', label: '用户名注册' }
];
const canSubmit = computed(() => {
if (mode.value === 'password') {
return mobile.value.length >= 5 && password.value.length >= 1;
}
return mobile.value.length >= 5 && code.value.length >= 1;
});
const visibleRegisterMethods = computed(() => {
const enabled = registerMethodOptions.filter(item => item.key !== 'email' && registerMethods.value[item.key]);
return enabled.length ? enabled : [registerMethodOptions[0]];
});
const canRegisterSubmit = computed(() => {
if (registerType.value === 'phone') {
return registerForm.phone.length >= 5
&& (mobileRealVerify.value ? registerForm.code.length >= 1 : phoneCaptchaMatched.value);
}
if (registerType.value === 'email') {
return false;
}
return registerForm.username.length >= 3
&& registerForm.usernamePhone.length >= 5
&& usernameCaptchaMatched.value
&& registerForm.password.length >= 1
&& registerForm.password === registerForm.confirmPassword;
});
const canSetRegisterPassword = computed(() => {
return pendingRegistration.mobile
&& pendingRegistration.resetCode
&& registerPasswordForm.password.length >= 1
&& registerPasswordForm.password === registerPasswordForm.confirmPassword;
});
const phoneCaptchaStatus = computed(() => getCaptchaStatus(registerForm.code, phoneCaptcha.value));
const phoneCaptchaMatched = computed(() => phoneCaptchaStatus.value === 'valid');
const phoneCaptchaClass = computed(() => getCaptchaClass(phoneCaptchaStatus.value));
const usernameCaptchaStatus = computed(() => {
return getCaptchaStatus(registerForm.usernameCode, usernameCaptcha.value);
});
const usernameCaptchaMatched = computed(() => usernameCaptchaStatus.value === 'valid');
const usernameCaptchaClass = computed(() => getCaptchaClass(usernameCaptchaStatus.value));
watch(visibleRegisterMethods, methods => {
if (!methods.some(item => item.key === registerType.value)) {
registerType.value = methods[0]?.key || 'phone';
}
});
onMounted(() => {
loadPlatformConfig();
loadRegisterConfig();
restorePendingRegistration();
});
async function requestCode() {
info.value = '';
sendingCode.value = true;
try {
await imClient.requestAuthCode(mobile.value);
info.value = '验证码已发送;本地测试也可以直接使用 66666';
} catch (error) {
session.error = error.message || '验证码发送失败';
} finally {
sendingCode.value = false;
}
}
async function requestRegisterCode() {
info.value = '';
sendingCode.value = true;
try {
await imClient.requestAuthCode(formatPhoneForApi(registerForm.phoneCountryCode, registerForm.phone));
info.value = '验证码已发送;本地测试也可以直接使用 66666';
} catch (error) {
session.error = error.message || '验证码发送失败';
} finally {
sendingCode.value = false;
}
}
function submit() {
if (mode.value === 'password') {
session.loginWithPassword(mobile.value, password.value);
return;
}
session.loginWithCode(mobile.value, code.value);
}
function normalizeUsernameCaptchaInput() {
registerForm.usernameCode = registerForm.usernameCode.replace(/\D/g, '').slice(0, 6);
}
function normalizePhoneCaptchaInput() {
registerForm.code = registerForm.code.replace(/\D/g, '').slice(0, 6);
}
function normalizeRegisterCountryCode() {
registerForm.phoneCountryCode = registerForm.phoneCountryCode.replace(/\D/g, '').slice(0, 6);
}
function showPhoneCaptcha() {
phoneCaptcha.value = randomDigits(6);
phoneCaptchaImage.value = renderCaptchaImage(phoneCaptcha.value);
registerForm.code = '';
}
function showUsernameCaptcha() {
usernameCaptcha.value = randomDigits(6);
usernameCaptchaImage.value = renderCaptchaImage(usernameCaptcha.value);
registerForm.usernameCode = '';
}
function getCaptchaStatus(inputValue, captchaValue) {
if (inputValue.length < 6) {
return '';
}
return captchaValue && inputValue === captchaValue ? 'valid' : 'invalid';
}
function getCaptchaClass(status) {
return {
'captcha-input-valid': status === 'valid',
'captcha-input-invalid': status === 'invalid'
};
}
function randomDigits(length) {
let value = '';
for (let index = 0; index < length; index += 1) {
value += Math.floor(Math.random() * 10);
}
return value;
}
function renderCaptchaImage(text) {
const width = 112;
const height = 42;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
context.fillStyle = '#f7fbfa';
context.fillRect(0, 0, width, height);
for (let index = 0; index < 11; index += 1) {
context.beginPath();
context.moveTo(Math.random() * width, Math.random() * height);
context.lineTo(Math.random() * width, Math.random() * height);
context.strokeStyle = `rgba(${25 + Math.random() * 60}, ${55 + Math.random() * 70}, ${55 + Math.random() * 70}, 0.82)`;
context.lineWidth = 1.4;
context.stroke();
}
context.font = '700 23px Arial, sans-serif';
context.textBaseline = 'middle';
context.textAlign = 'center';
for (let index = 0; index < text.length; index += 1) {
context.fillStyle = index % 2 === 0 ? '#153f3b' : '#1b6760';
context.fillText(text[index], 14 + index * 17, height / 2 + 1);
}
for (let index = 0; index < 70; index += 1) {
context.fillStyle = `rgba(20, 70, 65, ${0.12 + Math.random() * 0.2})`;
context.fillRect(Math.random() * width, Math.random() * height, 1.2, 1.2);
}
return canvas.toDataURL('image/png');
}
async function submitRegister() {
session.error = '';
info.value = '';
if (registerType.value === 'phone') {
clearRegisterPhoneError();
if (!mobileRealVerify.value && !phoneCaptchaMatched.value) {
session.error = '请输入正确的验证码';
return;
}
const formattedPhone = formatPhoneForApi(registerForm.phoneCountryCode, registerForm.phone);
const phoneExists = await isRegisterPhoneExists(formattedPhone);
if (phoneExists === null) {
return;
}
if (phoneExists) {
registerPhoneError.value = '该手机号已经注册过';
return;
}
const loginCode = mobileRealVerify.value ? registerForm.code : '66666';
const result = await session.registerWithPhone(formattedPhone, loginCode);
if (!result) {
return;
}
if (!result.resetCode) {
session.error = '注册成功,但没有收到设置密码凭证';
return;
}
setPendingRegistration(formattedPhone, result);
registerPasswordForm.password = '';
registerPasswordForm.confirmPassword = '';
view.value = 'setPassword';
return;
}
if (registerType.value === 'email') {
session.error = '邮箱注册已暂时关闭,请使用手机号注册';
return;
}
session.error = registerType.value === 'email'
? '邮箱注册接口还未接入,请先使用手机号注册'
: '用户名注册接口还未接入,请先使用手机号注册';
}
async function submitRegisterPassword() {
session.error = '';
info.value = '';
if (!pendingRegistration.mobile || !pendingRegistration.resetCode) {
session.error = '注册状态已失效,请重新注册';
return;
}
if (!registerPasswordForm.password || !registerPasswordForm.confirmPassword) {
session.error = '请填写完整密码';
return;
}
if (registerPasswordForm.password !== registerPasswordForm.confirmPassword) {
session.error = '两次输入的密码不一致';
return;
}
settingRegisterPassword.value = true;
try {
await session.resetPasswordWithCode({
mobile: pendingRegistration.mobile,
resetCode: pendingRegistration.resetCode,
newPassword: registerPasswordForm.password
});
await session.loginWithPassword(pendingRegistration.mobile, registerPasswordForm.password);
if (!session.error) {
clearPendingRegistration();
}
} catch (error) {
session.error = error.message || '密码设置失败';
} finally {
settingRegisterPassword.value = false;
}
}
function formatPhoneForApi(countryCode, phone) {
const digits = String(countryCode || '86').replace(/\D/g, '') || '86';
const normalizedCode = `+${digits}`;
const normalizedPhone = String(phone || '').replace(/\s/g, '');
if (normalizedCode === '+86') {
return normalizedPhone;
}
return `${normalizedCode}${normalizedPhone}`;
}
async function loadRegisterConfig() {
try {
const response = await axios.get(appConfig.registerConfigApi, { withCredentials: false });
const methods = response.data?.data?.methods;
if (methods) {
registerMethods.value = {
phone: Boolean(methods.phone),
email: emailRegisterEnabled && Boolean(methods.email),
username: Boolean(methods.username)
};
}
mobileRealVerify.value = Boolean(response.data?.data?.mobile_real_verify);
} catch (error) {
registerMethods.value = { phone: true, email: false, username: false };
mobileRealVerify.value = false;
}
}
async function loadPlatformConfig() {
try {
const response = await axios.get(appConfig.platformConfigApi, { withCredentials: false });
if (response.data?.code !== 0) {
throw new Error(response.data?.message || '平台配置加载失败');
}
const config = response.data?.result || {};
const name = String(config.platformName || '').trim();
const logo = String(config.logoUrl || config.logo || '').trim();
const version = String(config.version || '').trim();
platformName.value = name || defaultPlatformName;
platformLogo.value = resolveBrandLogo(logo, version);
} catch (error) {
platformName.value = defaultPlatformName;
platformLogo.value = defaultPlatformLogo;
}
applyDocumentBrand();
}
function resolveBrandLogo(value, version = '') {
const logo = String(value || '').trim();
if (!logo) {
return defaultPlatformLogo;
}
if (/^(https?:|data:|blob:)/i.test(logo)) {
return appendBrandVersion(logo, version);
}
if (logo.startsWith('//')) {
return appendBrandVersion(`${window.location.protocol}${logo}`, version);
}
const base = logo.startsWith('/') ? appConfig.adminServer : `${appConfig.adminServer}/`;
return appendBrandVersion(new URL(logo, base).toString(), version);
}
function appendBrandVersion(url, version) {
if (!version || /^(data:|blob:)/i.test(url)) {
return url;
}
const parsed = new URL(url, window.location.href);
parsed.searchParams.set('v', version);
return parsed.toString();
}
function applyDocumentBrand() {
if (typeof document === 'undefined') {
return;
}
document.title = platformName.value || defaultPlatformName;
let icon = document.querySelector('link[rel="icon"]');
if (!icon) {
icon = document.createElement('link');
icon.setAttribute('rel', 'icon');
document.head.appendChild(icon);
}
icon.setAttribute('href', platformLogo.value || defaultPlatformLogo);
}
function handleBrandLogoError() {
if (platformLogo.value !== defaultPlatformLogo) {
platformLogo.value = defaultPlatformLogo;
applyDocumentBrand();
}
}
async function isRegisterPhoneExists(phone) {
try {
const response = await axios.post(appConfig.registerPhoneExistsApi, { mobile: phone }, { withCredentials: false });
return Boolean(response.data?.data?.exists);
} catch (error) {
session.error = error.response?.data?.msg || error.message || '手机号检查失败,请稍后重试';
return null;
}
}
function clearRegisterPhoneError() {
registerPhoneError.value = '';
}
function clearPendingRegistration() {
pendingRegistration.mobile = '';
pendingRegistration.result = null;
pendingRegistration.resetCode = '';
registerPasswordForm.password = '';
registerPasswordForm.confirmPassword = '';
sessionStorage.removeItem(pendingRegistrationStorageKey);
}
function setPendingRegistration(phone, result) {
pendingRegistration.mobile = phone;
pendingRegistration.result = result;
pendingRegistration.resetCode = String(result.resetCode || '');
sessionStorage.setItem(pendingRegistrationStorageKey, JSON.stringify({
appServer: appConfig.appServer,
mobile: pendingRegistration.mobile,
resetCode: pendingRegistration.resetCode,
result
}));
}
function restorePendingRegistration() {
try {
const pending = JSON.parse(sessionStorage.getItem(pendingRegistrationStorageKey) || 'null');
if (!pending || pending.appServer !== appConfig.appServer || !pending.mobile || !pending.resetCode) {
return;
}
pendingRegistration.mobile = pending.mobile;
pendingRegistration.result = pending.result || null;
pendingRegistration.resetCode = String(pending.resetCode || '');
registerPasswordForm.password = '';
registerPasswordForm.confirmPassword = '';
view.value = 'setPassword';
} catch (error) {
sessionStorage.removeItem(pendingRegistrationStorageKey);
}
}
function openRegister() {
session.error = '';
info.value = '';
clearRegisterPhoneError();
clearPendingRegistration();
registerForm.phone = registerForm.phone || mobile.value;
registerForm.code = '';
phoneCaptcha.value = '';
phoneCaptchaImage.value = '';
view.value = 'register';
loadRegisterConfig();
}
function backToLogin() {
session.error = '';
info.value = '';
clearRegisterPhoneError();
clearPendingRegistration();
view.value = 'login';
}
</script>
File diff suppressed because it is too large Load Diff
+223
View File
@@ -0,0 +1,223 @@
<template>
<form
class="message-composer"
:class="{ 'drop-active': dropActive }"
@submit.prevent="submit"
@dragenter.prevent="handleDragEnter"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
@paste="handlePaste"
>
<div v-if="quoteMessage" class="quote-preview">
<span>
<strong>引用</strong>
{{ quoteMessage.quoteText || quoteMessage.text }}
</span>
<button type="button" title="取消引用" @click="$emit('cancel-quote')">×</button>
</div>
<div class="composer-tools">
<button type="button" title="表情" :disabled="disabled" @click="toggleEmojiPicker">☺</button>
<button type="button" title="图片" :disabled="disabled" @click="chooseImage">▧</button>
<button type="button" title="文件" :disabled="disabled" @click="chooseFile">▤</button>
<input ref="imageInput" type="file" accept="image/*" multiple @change="selectAttachment($event, 'image')">
<input ref="fileInput" type="file" multiple @change="selectAttachment($event, 'file')">
<div v-if="emojiOpen" class="emoji-picker" @click.stop>
<button
v-for="emoji in emojiList"
:key="emoji"
type="button"
@click="insertEmoji(emoji)"
>
{{ emoji }}
</button>
</div>
</div>
<textarea
ref="textareaEl"
v-model="text"
:disabled="disabled"
placeholder="输入消息"
rows="3"
@keydown.enter.exact.prevent="submit"
></textarea>
<div class="composer-footer">
<span>Enter 发送,Shift + Enter 换行</span>
<button class="send-button" :disabled="disabled || !text.trim()">发送</button>
</div>
</form>
</template>
<script setup>
import { ref } from 'vue';
const props = defineProps({
disabled: {
type: Boolean,
default: false
},
quoteMessage: {
type: Object,
default: null
}
});
const emit = defineEmits(['send', 'send-attachment', 'cancel-quote']);
const text = ref('');
const emojiOpen = ref(false);
const dropActive = ref(false);
const textareaEl = ref(null);
const imageInput = ref(null);
const fileInput = ref(null);
let dragDepth = 0;
const emojiList = [
'😀', '😄', '😂', '😊', '😍', '😘', '😎', '😢',
'😡', '👍', '🙏', '👏', '💪', '🎉', '❤️', '🔥',
'🌹', '☕', '✅', '⭐', '💰', '🎁', '🤝', '👌'
];
function submit() {
const value = text.value.trim();
if (!value) {
return;
}
emit('send', value);
text.value = '';
emojiOpen.value = false;
}
function toggleEmojiPicker() {
emojiOpen.value = !emojiOpen.value;
}
function insertEmoji(emoji) {
const element = textareaEl.value;
if (!element) {
text.value += emoji;
return;
}
const start = element.selectionStart ?? text.value.length;
const end = element.selectionEnd ?? text.value.length;
text.value = `${text.value.slice(0, start)}${emoji}${text.value.slice(end)}`;
requestAnimationFrame(() => {
element.focus();
const cursor = start + emoji.length;
element.setSelectionRange(cursor, cursor);
});
}
function chooseImage() {
imageInput.value?.click();
}
function chooseFile() {
fileInput.value?.click();
}
function attachmentTypeForFile(file, fallbackType = 'file') {
if (file?.type?.startsWith('image/')) {
return 'image';
}
return fallbackType === 'image' ? 'image' : 'file';
}
function emitFiles(files, fallbackType = 'file') {
if (props.disabled) {
return;
}
const validFiles = Array.from(files || []).filter(file => file instanceof File && file.size > 0);
if (!validFiles.length) {
return;
}
const caption = text.value.trim();
validFiles.forEach((file, index) => {
emit('send-attachment', {
type: attachmentTypeForFile(file, fallbackType),
file,
text: index === 0 ? caption : ''
});
});
text.value = '';
emojiOpen.value = false;
}
function selectAttachment(event, type) {
const input = event.target;
const files = input.files ? Array.from(input.files) : [];
input.value = '';
if (!files.length || props.disabled) {
return;
}
emitFiles(files, type);
}
function hasDraggedFiles(event) {
const types = Array.from(event.dataTransfer?.types || []);
return types.includes('Files');
}
function handleDragEnter(event) {
if (props.disabled || !hasDraggedFiles(event)) {
return;
}
dragDepth += 1;
dropActive.value = true;
event.dataTransfer.dropEffect = 'copy';
}
function handleDragOver(event) {
if (props.disabled || !hasDraggedFiles(event)) {
return;
}
dropActive.value = true;
event.dataTransfer.dropEffect = 'copy';
}
function handleDragLeave() {
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) {
dropActive.value = false;
}
}
function filesFromDataTransfer(dataTransfer) {
const items = Array.from(dataTransfer?.items || []);
const files = items
.filter(item => item.kind === 'file' && !item.webkitGetAsEntry?.()?.isDirectory)
.map(item => item.getAsFile())
.filter(Boolean);
if (files.length) {
return files;
}
return Array.from(dataTransfer?.files || []);
}
function handleDrop(event) {
dragDepth = 0;
dropActive.value = false;
if (props.disabled) {
return;
}
emitFiles(filesFromDataTransfer(event.dataTransfer));
}
function imageFilesFromClipboard(clipboardData) {
return Array.from(clipboardData?.items || [])
.filter(item => item.kind === 'file' && item.type.startsWith('image/'))
.map(item => item.getAsFile())
.filter(Boolean);
}
function handlePaste(event) {
if (props.disabled) {
return;
}
const imageFiles = imageFilesFromClipboard(event.clipboardData);
if (!imageFiles.length) {
return;
}
event.preventDefault();
emitFiles(imageFiles, 'image');
}
</script>
+757
View File
@@ -0,0 +1,757 @@
<template>
<div ref="listEl" class="message-list">
<article
v-for="message in messages"
:key="message.id"
class="message-row"
:class="[message.direction, { selected: isSelected(message), attention: isAttention(message), recalled: message.recalled || message.status === 'recalled' }]"
:data-message-key="messageIdentity(message)"
@click="handleMessageClick(message)"
>
<label v-if="selectionMode" class="message-select">
<input
type="checkbox"
:checked="isSelected(message)"
@click.stop
@change="emit('toggle-select', message)"
>
</label>
<button
v-if="showGroupSender(message)"
type="button"
class="message-sender-avatar"
:title="groupSenderName(message)"
@click.stop="openSenderProfile(message)"
>
<img v-if="message.senderPortrait" :src="message.senderPortrait" alt="">
<span v-else>{{ groupSenderAvatarText(message) }}</span>
</button>
<div class="message-stack">
<button
v-if="showGroupSender(message)"
type="button"
class="message-sender-name"
@click.stop="openSenderProfile(message)"
>
{{ groupSenderName(message) }}
<span v-if="senderRoleLabel(message)" class="message-sender-role">{{ senderRoleLabel(message) }}</span>
</button>
<div
class="message-bubble"
:class="roleBubbleClass(message)"
@contextmenu.prevent="openMenu($event, message)"
>
<button
v-if="isImageMessage(message)"
type="button"
class="message-image"
@click.stop="handleImageClick(message)"
>
<img :src="message.attachment.url" :alt="message.attachment.name || '图片'">
</button>
<a
v-else-if="isFileMessage(message)"
class="message-file"
:href="message.attachment.url"
target="_blank"
rel="noreferrer"
download
>
<span class="message-file-icon">▤</span>
<span class="message-file-main">
<strong>{{ message.attachment.name || '文件' }}</strong>
<small>{{ formatFileSize(message.attachment.size) }}</small>
</span>
</a>
<p v-if="message.text">{{ message.text }}</p>
<time>
<span v-if="isRecallNotice(message)" class="recall-mark">{{ message.recallStatus || '已撤回' }}</span>
<span>{{ formatTime(message.displayTimestamp || message.timestamp) }}</span>
<span v-if="message.editedAt" class="edit-mark">【edit {{ formatTime(message.editedAt) }}】</span>
<span v-if="isFavorite(message)" class="favorite-mark" title="已收藏" aria-label="已收藏">★</span>
<span
v-if="deliveryState(message)"
class="delivery-mark"
:class="`delivery-mark-${deliveryState(message)}`"
:title="deliveryTitle(message)"
:aria-label="deliveryTitle(message)"
>
<span class="delivery-check">✓</span>
<span v-if="deliveryState(message) !== 'sent'" class="delivery-check delivery-check-second">✓</span>
</span>
<span v-if="message.status === 'failed'" class="delivery-error">{{ failureText(message) }}</span>
</time>
</div>
</div>
</article>
<div
v-if="contextMenu.visible"
class="message-context-menu"
:style="{ left: `${contextMenu.x}px`, top: `${contextMenu.y}px` }"
@click.stop
>
<button
v-for="item in menuItems"
:key="item.action"
type="button"
@click="chooseMenuAction(item.action)"
>
{{ item.label }}
</button>
</div>
<Teleport to="body">
<div
v-if="imageViewer.visible"
class="image-viewer-backdrop"
@click="closeImageViewer"
>
<div
class="image-viewer-dialog"
@click.stop
>
<div class="image-viewer-toolbar">
<span class="image-viewer-title">{{ imageViewer.name || '图片' }}</span>
<button type="button" title="缩小" aria-label="缩小" @click="zoomImage(-0.25)">−</button>
<button type="button" title="放大" aria-label="放大" @click="zoomImage(0.25)">+</button>
<button type="button" title="还原" aria-label="还原" @click="resetImageViewer">1:1</button>
<button type="button" title="旋转" aria-label="旋转" @click="rotateImage">⟳</button>
<button type="button" title="保存" aria-label="保存" @click="saveViewerImage">⇩</button>
<button type="button" title="关闭" aria-label="关闭" @click="closeImageViewer">×</button>
</div>
<div
ref="viewerStage"
class="image-viewer-stage"
:class="{ dragging: imageViewer.dragging, zoomed: imageViewer.scale > 1 }"
@wheel.prevent="handleImageWheel"
@pointerdown.prevent="startImageDrag"
>
<img
class="image-viewer-img"
:src="imageViewer.url"
:alt="imageViewer.name || '图片'"
:style="imageViewerStyle"
draggable="false"
>
</div>
</div>
</div>
</Teleport>
<Teleport to="body">
<div
v-if="senderProfile.visible"
class="sender-profile-backdrop"
@click="closeSenderProfile"
>
<section class="sender-profile-dialog" @click.stop>
<header>
<strong>用户资料</strong>
<button type="button" class="icon-button" title="关闭" @click="closeSenderProfile">×</button>
</header>
<div class="sender-profile-main">
<img v-if="senderProfile.portrait" class="profile-avatar image-avatar" :src="senderProfile.portrait" alt="">
<div v-else class="profile-avatar">{{ senderProfile.avatarText }}</div>
<strong>{{ senderProfile.displayName || senderProfile.uid }}</strong>
<span v-if="senderProfile.roleLabel">{{ senderProfile.roleLabel }}</span>
</div>
<dl class="sender-profile-fields">
<div>
<dt>用户ID</dt>
<dd>{{ senderProfile.uid || '-' }}</dd>
</div>
<div>
<dt>昵称</dt>
<dd>{{ senderProfile.rawName || senderProfile.displayName || '-' }}</dd>
</div>
<div>
<dt>手机号</dt>
<dd>{{ senderProfile.mobile || '未知' }}</dd>
</div>
<div>
<dt>地区</dt>
<dd>{{ senderProfile.address || '未知' }}</dd>
</div>
</dl>
<label class="sender-profile-alias">
<span>备注名称</span>
<input
v-model.trim="senderProfile.aliasDraft"
maxlength="32"
autocomplete="off"
placeholder="可给群员设置备注"
:disabled="senderProfile.saving || senderProfile.adding"
>
</label>
<footer class="sender-profile-actions">
<span v-if="senderProfile.notice" class="inline-save-status">{{ senderProfile.notice }}</span>
<span v-else-if="senderProfile.error" class="inline-save-status error">{{ senderProfile.error }}</span>
<button
v-if="!senderProfile.isContact"
type="button"
class="ghost-inline-action"
:disabled="senderProfile.adding || senderProfile.saving || !senderProfile.uid"
@click="addSenderAsContact"
>
{{ senderProfile.adding ? '添加中...' : '添加为好友' }}
</button>
<button
type="button"
class="primary-inline-action"
:disabled="senderProfile.saving || senderProfile.adding || !senderProfile.uid"
@click="saveSenderAlias"
>
{{ senderProfile.saving ? '保存中...' : '保存备注' }}
</button>
</footer>
</section>
</div>
</Teleport>
</div>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { useContactStore } from '@/stores/contacts';
const props = defineProps({
messages: {
type: Array,
default: () => []
},
selectionMode: {
type: Boolean,
default: false
},
selectedMessageIds: {
type: Array,
default: () => []
},
favoriteMessageIds: {
type: Array,
default: () => []
},
attentionMessageId: {
type: String,
default: ''
},
canRecallOthers: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['message-action', 'toggle-select', 'sender-profile-updated']);
const contacts = useContactStore();
const listEl = ref(null);
const viewerStage = ref(null);
const contextMenu = reactive({
visible: false,
x: 0,
y: 0,
message: null
});
const IMAGE_MIN_SCALE = 0.5;
const IMAGE_MAX_SCALE = 6;
const imageViewer = reactive({
visible: false,
url: '',
name: '',
scale: 1,
rotation: 0,
offsetX: 0,
offsetY: 0,
dragging: false,
dragStartX: 0,
dragStartY: 0,
dragOffsetX: 0,
dragOffsetY: 0
});
const senderProfile = reactive({
visible: false,
uid: '',
displayName: '',
rawName: '',
mobile: '',
address: '',
portrait: '',
avatarText: '',
role: '',
roleLabel: '',
contactAlias: '',
groupAlias: '',
isContact: false,
aliasDraft: '',
saving: false,
adding: false,
notice: '',
error: ''
});
const imageViewerStyle = computed(() => ({
transform: `translate3d(${imageViewer.offsetX}px, ${imageViewer.offsetY}px, 0) scale(${imageViewer.scale}) rotate(${imageViewer.rotation}deg)`
}));
const menuItems = computed(() => {
if (!contextMenu.message) {
return [];
}
const isOwnMessage = contextMenu.message.direction === 'out';
if (isRecalledMessage(contextMenu.message)) {
return [
...(isOwnMessage ? [{ action: 'edit', label: '编辑' }] : []),
{ action: 'copy', label: '复制' },
{ action: 'delete', label: '删除' },
{ action: 'detail', label: '详情' }
];
}
const favoriteLabel = isFavorite(contextMenu.message) ? '取消收藏' : '收藏';
const items = [
{ action: 'copy', label: '复制' },
{ action: 'delete', label: '删除' },
{ action: 'forward', label: '转发' },
{ action: 'favorite', label: favoriteLabel },
{ action: 'quote', label: '引用' },
{ action: 'select', label: '多选' },
{ action: 'detail', label: '详情' }
];
if (isOwnMessage) {
items.unshift({ action: 'edit', label: '编辑' });
items.push({ action: 'recall', label: '撤回' });
} else if (props.canRecallOthers) {
items.push({ action: 'recall', label: '撤回' });
}
return items;
});
function scrollToBottom() {
nextTick(() => {
if (listEl.value) {
listEl.value.scrollTop = listEl.value.scrollHeight;
}
});
}
function scrollToMessage(messageId) {
nextTick(() => {
if (!listEl.value || !messageId) {
return;
}
const row = Array.from(listEl.value.querySelectorAll('.message-row'))
.find(element => element.dataset.messageKey === messageId);
if (row) {
row.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
});
}
watch(
() => props.messages.map(message => `${message.id}:${message.status}:${message.deliveryStatus || ''}:${message.messageUid || ''}:${message.recalled ? '1' : ''}`).join('|'),
scrollToBottom,
{ immediate: true }
);
watch(
() => props.attentionMessageId,
scrollToMessage
);
onMounted(() => {
document.addEventListener('click', closeMenu);
document.addEventListener('keydown', closeMenuOnEscape);
document.addEventListener('pointermove', moveImageDrag);
document.addEventListener('pointerup', stopImageDrag);
});
onBeforeUnmount(() => {
document.removeEventListener('click', closeMenu);
document.removeEventListener('keydown', closeMenuOnEscape);
document.removeEventListener('pointermove', moveImageDrag);
document.removeEventListener('pointerup', stopImageDrag);
});
function messageIdentity(message) {
return String(message.messageUid || message.id || '');
}
function canOpenMessageMenu(message) {
return message && message.status !== 'failed' && message.status !== 'sending';
}
function isSelected(message) {
return props.selectedMessageIds.includes(messageIdentity(message));
}
function isFavorite(message) {
return props.favoriteMessageIds.includes(messageIdentity(message));
}
function isAttention(message) {
return props.attentionMessageId === messageIdentity(message);
}
function isRecallNotice(message) {
return message.direction === 'out' && (message.recalled || message.status === 'recalled');
}
function isRecalledMessage(message) {
return message.recalled || message.status === 'recalled';
}
function isImageMessage(message) {
return message?.type === 'image' && message?.attachment?.url;
}
function isFileMessage(message) {
return message?.type === 'file' && message?.attachment?.url;
}
function isGroupMessage(message) {
return ['group', 'channel'].includes(message?.conversationType || '');
}
function showGroupSender(message) {
return isGroupMessage(message) && message?.direction === 'in';
}
function senderRoleLabel(message) {
const role = message?.senderProfile?.role || message?.senderRole || '';
if (role === 'owner') {
return '群主';
}
if (role === 'admin') {
return '管理员';
}
return '';
}
function groupSenderName(message) {
return message?.senderName || message?.senderProfile?.displayName || message?.from || '未知用户';
}
function groupSenderAvatarText(message) {
return String(groupSenderName(message) || '?').slice(0, 1).toUpperCase();
}
function openSenderProfile(message) {
const profile = message?.senderProfile || {};
const uid = profile.uid || message?.from || '';
const cachedContact = contacts.contacts.find(contact => String(contact.uid || contact.id || '') === uid) || null;
const cachedAlias = String(cachedContact?.alias || '').trim();
const contactAlias = String(profile.contactAlias || cachedAlias || profile.remarkAlias || '').trim();
const groupAlias = String(profile.groupAlias || '').trim();
const rawName = profile.rawName
|| cachedContact?.rawUser?.displayName
|| cachedContact?.rawUser?.name
|| cachedContact?.title
|| groupSenderName(message);
const displayName = contactAlias || groupAlias || rawName || uid;
senderProfile.visible = true;
senderProfile.uid = uid;
senderProfile.displayName = displayName;
senderProfile.rawName = rawName || '';
senderProfile.mobile = profile.mobile || cachedContact?.rawUser?.mobile || '';
senderProfile.address = profile.address || cachedContact?.address || cachedContact?.rawUser?.address || '';
senderProfile.portrait = profile.portrait || cachedContact?.portrait || message?.senderPortrait || '';
senderProfile.avatarText = String(displayName || senderProfile.uid || '?').slice(0, 1).toUpperCase();
senderProfile.role = profile.role || message?.senderRole || '';
senderProfile.roleLabel = senderRoleLabel(message);
senderProfile.contactAlias = contactAlias;
senderProfile.groupAlias = groupAlias;
senderProfile.isContact = Boolean(profile.isContact || cachedContact);
senderProfile.aliasDraft = contactAlias;
senderProfile.saving = false;
senderProfile.adding = false;
senderProfile.notice = '';
senderProfile.error = '';
closeMenu();
}
function closeSenderProfile() {
if (senderProfile.saving || senderProfile.adding) {
return;
}
senderProfile.visible = false;
}
async function addSenderAsContact() {
if (!senderProfile.uid || senderProfile.adding || senderProfile.saving) {
return;
}
senderProfile.adding = true;
senderProfile.notice = '';
senderProfile.error = '';
const ok = await contacts.addContact(senderProfile.uid);
if (ok || String(contacts.actionError || '').includes('已经是联系人')) {
senderProfile.isContact = true;
const alias = senderProfile.aliasDraft.trim();
let aliasSaved = false;
if (alias) {
aliasSaved = await contacts.updateContactAlias(senderProfile.uid, alias);
if (aliasSaved) {
senderProfile.contactAlias = alias;
senderProfile.displayName = alias;
senderProfile.avatarText = String(alias || senderProfile.uid || '?').slice(0, 1).toUpperCase();
}
}
await contacts.refreshContacts({ force: true }).catch(() => {});
if (alias && !aliasSaved) {
senderProfile.notice = '';
senderProfile.error = contacts.actionError
? `已添加为好友,但${contacts.actionError}`
: '已添加为好友,但备注名同步失败';
} else {
senderProfile.notice = alias ? '已添加为好友,备注名已保存' : (ok ? '已添加为好友' : '已是好友');
}
emit('sender-profile-updated', { userId: senderProfile.uid });
} else {
senderProfile.error = contacts.actionError || '添加好友失败';
}
senderProfile.adding = false;
}
async function saveSenderAlias() {
if (!senderProfile.uid || senderProfile.saving || senderProfile.adding) {
return;
}
senderProfile.saving = true;
senderProfile.notice = '';
senderProfile.error = '';
const ok = await contacts.updateContactAlias(senderProfile.uid, senderProfile.aliasDraft);
if (ok) {
await contacts.refreshContacts({ force: true }).catch(() => {});
const alias = senderProfile.aliasDraft.trim();
senderProfile.contactAlias = alias;
senderProfile.displayName = alias || senderProfile.groupAlias || senderProfile.rawName || senderProfile.uid;
senderProfile.avatarText = String(senderProfile.displayName || senderProfile.uid || '?').slice(0, 1).toUpperCase();
senderProfile.notice = '备注名已保存';
emit('sender-profile-updated', { userId: senderProfile.uid });
} else {
senderProfile.error = contacts.actionError || '备注名修改失败';
}
senderProfile.saving = false;
}
function roleBubbleClass(message) {
const role = message?.senderRole || '';
if (role === 'owner') {
return 'role-bubble-owner';
}
if (role === 'admin') {
return 'role-bubble-admin';
}
return '';
}
function formatFileSize(size) {
const bytes = Number(size || 0);
if (bytes >= 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(bytes >= 10 * 1024 * 1024 ? 0 : 1)} MB`;
}
if (bytes >= 1024) {
return `${(bytes / 1024).toFixed(bytes >= 10 * 1024 ? 0 : 1)} KB`;
}
return bytes > 0 ? `${bytes} B` : '';
}
function handleMessageClick(message) {
if (props.selectionMode) {
emit('toggle-select', message);
}
}
function handleImageClick(message) {
if (props.selectionMode) {
emit('toggle-select', message);
return;
}
openImageViewer(message);
}
function openImageViewer(message) {
const attachment = message?.attachment || {};
if (!attachment.url) {
return;
}
closeMenu();
imageViewer.visible = true;
imageViewer.url = attachment.url;
imageViewer.name = attachment.name || 'image';
resetImageViewer();
}
function closeImageViewer() {
imageViewer.visible = false;
imageViewer.dragging = false;
}
function resetImageViewer() {
imageViewer.scale = 1;
imageViewer.rotation = 0;
imageViewer.offsetX = 0;
imageViewer.offsetY = 0;
}
function clampImageScale(value) {
return Math.min(IMAGE_MAX_SCALE, Math.max(IMAGE_MIN_SCALE, Number(value.toFixed(2))));
}
function setImageScale(value, originX = null, originY = null) {
const previousScale = imageViewer.scale;
const nextScale = clampImageScale(value);
if (nextScale === previousScale) {
return;
}
if (originX !== null && originY !== null && viewerStage.value) {
const rect = viewerStage.value.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const ratio = nextScale / previousScale - 1;
imageViewer.offsetX -= (originX - centerX - imageViewer.offsetX) * ratio;
imageViewer.offsetY -= (originY - centerY - imageViewer.offsetY) * ratio;
}
imageViewer.scale = nextScale;
if (nextScale <= 1) {
imageViewer.offsetX = 0;
imageViewer.offsetY = 0;
}
}
function zoomImage(delta) {
setImageScale(imageViewer.scale + delta);
}
function rotateImage() {
imageViewer.rotation = (imageViewer.rotation + 90) % 360;
}
function handleImageWheel(event) {
const factor = event.deltaY < 0 ? 1.12 : 1 / 1.12;
setImageScale(imageViewer.scale * factor, event.clientX, event.clientY);
}
function startImageDrag(event) {
if (!imageViewer.visible || imageViewer.scale <= 1) {
return;
}
imageViewer.dragging = true;
imageViewer.dragStartX = event.clientX;
imageViewer.dragStartY = event.clientY;
imageViewer.dragOffsetX = imageViewer.offsetX;
imageViewer.dragOffsetY = imageViewer.offsetY;
}
function moveImageDrag(event) {
if (!imageViewer.dragging) {
return;
}
imageViewer.offsetX = imageViewer.dragOffsetX + event.clientX - imageViewer.dragStartX;
imageViewer.offsetY = imageViewer.dragOffsetY + event.clientY - imageViewer.dragStartY;
}
function stopImageDrag() {
imageViewer.dragging = false;
}
function saveViewerImage() {
if (!imageViewer.url) {
return;
}
const downloadUrl = new URL('/api/media_download.php', window.location.origin);
downloadUrl.searchParams.set('url', imageViewer.url);
downloadUrl.searchParams.set('name', imageViewer.name || 'image');
const link = document.createElement('a');
link.href = downloadUrl.toString();
link.download = imageViewer.name || 'image';
document.body.appendChild(link);
link.click();
link.remove();
}
function openMenu(event, message) {
if (!canOpenMessageMenu(message)) {
closeMenu();
return;
}
contextMenu.visible = true;
contextMenu.message = message;
contextMenu.x = Math.min(event.clientX, window.innerWidth - 148);
contextMenu.y = Math.min(event.clientY, window.innerHeight - 352);
}
function closeMenu() {
contextMenu.visible = false;
contextMenu.message = null;
}
function closeMenuOnEscape(event) {
if (event.key === 'Escape') {
if (senderProfile.visible) {
closeSenderProfile();
}
if (imageViewer.visible) {
closeImageViewer();
}
closeMenu();
}
}
function chooseMenuAction(action) {
if (contextMenu.message) {
emit('message-action', { action, message: contextMenu.message });
}
closeMenu();
}
function formatTime(timestamp) {
return new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone
}).format(timestamp);
}
function deliveryState(message) {
if (message.direction !== 'out' || message.status === 'failed' || message.status === 'recalled' || message.recalled) {
return '';
}
const state = message.deliveryStatus || message.status;
if (state === 'read') {
return 'read';
}
if (state === 'delivered') {
return 'delivered';
}
if (state === 'sent') {
return 'sent';
}
return '';
}
function deliveryTitle(message) {
const state = deliveryState(message);
if (state === 'read') {
return '对方已读';
}
if (state === 'delivered') {
return '已送达对方';
}
if (state === 'sent') {
return '已送出';
}
return '';
}
function failureText(message) {
if (message.status === 'failed') {
if (message.errorCode) {
return message.error ? `失败:${message.error}(${message.errorCode})` : `失败:${message.errorCode}`;
}
return message.error ? `失败:${message.error}` : '失败';
}
return '';
}
</script>
+111
View File
@@ -0,0 +1,111 @@
<template>
<section class="contact-panel">
<template v-if="contact">
<header class="chat-header">
<div>
<h2>{{ remarkName }}</h2>
<p>{{ username }}</p>
</div>
</header>
<div class="contact-profile">
<img v-if="contact.portrait" class="profile-avatar image-avatar" :src="contact.portrait" alt="">
<div v-else class="profile-avatar">{{ contact.avatarText }}</div>
<div class="profile-main">
<div class="remark-row">
<h3>{{ remarkName }}</h3>
<button type="button" class="icon-button remark-edit-button" title="编辑备注名" @click="openAliasEditor">✏️</button>
</div>
</div>
<dl class="profile-fields">
<div>
<dt>用户名</dt>
<dd>{{ username }}</dd>
</div>
<div>
<dt>手机号</dt>
<dd>{{ mobile }}</dd>
</div>
<div>
<dt>地区</dt>
<dd>{{ address }}</dd>
</div>
</dl>
<button class="primary-action profile-action" @click="contacts.openChat(contact)">发消息</button>
</div>
<div v-if="editingAlias" class="modal-backdrop contact-modal-backdrop" @click.self="closeAliasEditor">
<form class="modal-panel" @submit.prevent="submitAlias">
<header>
<strong>编辑备注名</strong>
<button type="button" class="icon-button" title="关闭" @click="closeAliasEditor">×</button>
</header>
<label>
<span>备注名</span>
<input v-model.trim="aliasDraft" maxlength="32" autocomplete="off" autofocus>
</label>
<p v-if="aliasError" class="form-error">{{ aliasError }}</p>
<div class="modal-actions">
<button type="button" class="ghost-inline-action" :disabled="aliasSaving" @click="closeAliasEditor">取消</button>
<button type="submit" class="primary-inline-action" :disabled="aliasSaving">{{ aliasSaving ? '保存中' : '保存' }}</button>
</div>
</form>
</div>
</template>
<div v-else class="empty-chat">
<strong>选择一个联系人</strong>
<span>好友列表同步完成后,可以在左侧查看资料</span>
</div>
</section>
</template>
<script setup>
import { computed, ref, watch } from 'vue';
import { useContactStore } from '@/stores/contacts';
const contacts = useContactStore();
const contact = computed(() => contacts.activeContact);
const remarkName = computed(() => contact.value?.alias || '未设置');
const username = computed(() => contact.value?.rawUser?.displayName || contact.value?.rawUser?.name || contact.value?.uid || '');
const mobile = computed(() => contact.value?.rawUser?.mobile || '未知');
const address = computed(() => contact.value?.rawUser?.address || '未知');
const editingAlias = ref(false);
const aliasDraft = ref('');
const aliasSaving = ref(false);
const aliasError = ref('');
watch(contact, () => {
editingAlias.value = false;
aliasError.value = '';
});
function openAliasEditor() {
aliasDraft.value = contact.value?.alias || '';
aliasError.value = '';
editingAlias.value = true;
}
function closeAliasEditor() {
if (aliasSaving.value) {
return;
}
editingAlias.value = false;
aliasError.value = '';
}
async function submitAlias() {
if (!contact.value) {
return;
}
aliasSaving.value = true;
aliasError.value = '';
const ok = await contacts.updateContactAlias(contact.value.uid, aliasDraft.value);
aliasSaving.value = false;
if (ok) {
editingAlias.value = false;
return;
}
aliasError.value = contacts.actionError || '备注名修改失败';
}
</script>
+79
View File
@@ -0,0 +1,79 @@
<template>
<aside class="conversation-list favorite-list">
<header class="list-header">
<div>
<h2>收藏</h2>
<p>{{ conversations.favoriteMessages.length }} 条消息</p>
</div>
</header>
<div class="sidebar-scroll">
<button
v-for="item in conversations.favoriteMessages"
:key="item.key"
type="button"
class="favorite-item"
:class="{ active: activeKey === item.key }"
@click="conversations.setActiveFavorite(item.key)"
>
<span class="favorite-item-icon">★</span>
<span class="favorite-item-main">
<strong>{{ favoriteTitle(item) }}</strong>
<small>{{ favoritePreview(item) }}</small>
</span>
<time>{{ formatFavoriteTime(item.timestamp) }}</time>
</button>
<div v-if="!conversations.favoriteMessages.length" class="empty-list">
暂无收藏消息
</div>
</div>
</aside>
</template>
<script setup>
import { computed } from 'vue';
import { useConversationStore } from '@/stores/conversations';
const conversations = useConversationStore();
const activeKey = computed(() => conversations.activeFavorite?.key || '');
function favoriteTitle(item) {
if (item.conversationTitle) {
return item.conversationTitle;
}
const conversation = conversations.conversations.find(entry => entry.id === item.conversationId);
return conversation?.title || item.conversationId || '未知会话';
}
function favoritePreview(item) {
if (item.recalled) {
return `[${item.recallStatus || '已撤回'}]`;
}
if (item.preview) {
return item.preview;
}
if (item.text) {
return item.text;
}
if (item.type === 'image') {
return '[图片]';
}
if (item.type === 'file') {
return `[文件] ${item.attachment?.name || '文件'}`;
}
return '(无文字内容)';
}
function formatFavoriteTime(timestamp) {
const value = Number(timestamp || 0);
if (!value) {
return '';
}
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
}).format(value);
}
</script>
+202
View File
@@ -0,0 +1,202 @@
<template>
<section class="favorite-panel">
<template v-if="favorite">
<header class="chat-header">
<div>
<h2>收藏详情</h2>
<p>{{ favoriteTitle(favorite) }}</p>
</div>
</header>
<div class="favorite-detail">
<div class="favorite-detail-message" :class="{ recalled: favorite.recalled }">
<span class="favorite-detail-star">★</span>
<div class="favorite-detail-body">
<span v-if="favorite.recalled" class="recall-mark">{{ favorite.recallStatus || '已撤回' }}</span>
<a
v-if="isImageFavorite(favorite)"
class="favorite-detail-image"
:href="favorite.attachment.url"
target="_blank"
rel="noreferrer"
>
<img :src="favorite.attachment.url" :alt="favorite.attachment.name || '图片'">
</a>
<a
v-else-if="isFileFavorite(favorite)"
class="message-file favorite-detail-file"
:href="favorite.attachment.url"
target="_blank"
rel="noreferrer"
download
>
<span class="message-file-icon">▤</span>
<span class="message-file-main">
<strong>{{ favorite.attachment.name || '文件' }}</strong>
<small>{{ formatFileSize(favorite.attachment.size) }}</small>
</span>
</a>
<p v-if="favoriteText(favorite)">{{ favoriteText(favorite) }}</p>
</div>
<time>{{ formatFavoriteTime(favorite.timestamp) }}</time>
</div>
<div class="favorite-detail-actions">
<span v-if="notice" class="inline-save-status">{{ notice }}</span>
<span v-else-if="error" class="inline-save-status error">{{ error }}</span>
<button type="button" class="ghost-inline-action" @click="copyFavorite">复制</button>
<button type="button" class="ghost-inline-action" @click="openConversation">打开原会话</button>
<button type="button" class="danger-inline-action" @click="removeFavorite">取消收藏</button>
</div>
</div>
</template>
<div v-else class="empty-chat">
<strong>暂无收藏消息</strong>
<span>右键消息气泡选择“收藏”后,会显示在这里</span>
</div>
</section>
</template>
<script setup>
import { computed, ref } from 'vue';
import { useConversationStore } from '@/stores/conversations';
import { useNavigationStore } from '@/stores/navigation';
const conversations = useConversationStore();
const navigation = useNavigationStore();
const favorite = computed(() => conversations.activeFavorite);
const notice = ref('');
const error = ref('');
function clearFeedback() {
notice.value = '';
error.value = '';
}
function favoriteTitle(item) {
if (item.conversationTitle) {
return item.conversationTitle;
}
const conversation = conversations.conversations.find(entry => entry.id === item.conversationId);
return conversation?.title || item.conversationId || '未知会话';
}
function favoriteText(item) {
return item.text || '';
}
function isImageFavorite(item) {
return item?.type === 'image' && item?.attachment?.url;
}
function isFileFavorite(item) {
return item?.type === 'file' && item?.attachment?.url;
}
function favoriteCopyText(item) {
if (!item) {
return '';
}
if (item.text) {
return item.text;
}
if (item.recalled) {
return item.recallStatus || '已撤回';
}
if (isImageFavorite(item)) {
return item.attachment.url;
}
if (isFileFavorite(item)) {
return `${item.attachment.name || '文件'} ${item.attachment.url}`.trim();
}
return item.preview || '';
}
function formatFileSize(size) {
const bytes = Number(size || 0);
if (bytes >= 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(bytes >= 10 * 1024 * 1024 ? 0 : 1)} MB`;
}
if (bytes >= 1024) {
return `${(bytes / 1024).toFixed(bytes >= 10 * 1024 ? 0 : 1)} KB`;
}
return bytes > 0 ? `${bytes} B` : '';
}
function formatFavoriteTime(timestamp) {
const value = Number(timestamp || 0);
if (!value) {
return '';
}
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
}).format(value);
}
async function copyFavorite() {
clearFeedback();
const text = favoriteCopyText(favorite.value || {}).trim();
if (!text) {
error.value = '没有可复制的文字内容';
return;
}
try {
await writeClipboard(text);
notice.value = '已复制';
} catch (copyError) {
error.value = '复制失败';
}
}
async function writeClipboard(text) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
const element = document.createElement('textarea');
element.value = text;
element.setAttribute('readonly', 'readonly');
element.style.position = 'fixed';
element.style.left = '-9999px';
document.body.appendChild(element);
element.select();
document.execCommand('copy');
document.body.removeChild(element);
}
function openConversation() {
clearFeedback();
const item = favorite.value;
if (!item?.conversationId) {
error.value = '缺少原会话信息';
return;
}
const conversation = conversations.conversations.find(entry => entry.id === item.conversationId);
if (!conversation) {
error.value = '原会话暂未加载,请刷新会话后再试';
conversations.refreshRemoteConversations();
return;
}
navigation.showMessages();
conversations.setActiveConversation(item.conversationId);
window.setTimeout(() => {
conversations.focusMessage({
id: item.id || item.key,
messageUid: item.messageUid || item.key
});
}, 450);
}
function removeFavorite() {
clearFeedback();
const key = favorite.value?.key || '';
if (!key) {
return;
}
conversations.removeFavorite(key);
notice.value = '已取消收藏';
}
</script>
+103
View File
@@ -0,0 +1,103 @@
<template>
<aside class="conversation-list">
<header class="list-header">
<div>
<h2>联系人</h2>
<p>{{ contacts.contacts.length }} 位好友</p>
</div>
<div class="header-actions">
<button class="icon-button" title="添加联系人" @click="openAddContact">+</button>
<button class="icon-button" title="刷新" @click="contacts.refreshContacts({ force: true })">↻</button>
</div>
</header>
<div class="search-box">
<input v-model.trim="keyword" placeholder="搜索联系人">
</div>
<div class="sidebar-scroll">
<button
v-for="item in filteredContacts"
:key="item.id"
class="conversation-item"
:class="{ active: item.id === contacts.activeId }"
@click="contacts.setActiveContact(item.id)"
>
<img v-if="item.portrait" class="conversation-avatar image-avatar" :src="item.portrait" alt="">
<span v-else class="conversation-avatar">{{ item.avatarText }}</span>
<span class="conversation-copy">
<strong>{{ item.title }}</strong>
<small>{{ item.subtitle }}</small>
</span>
</button>
<div v-if="!filteredContacts.length" class="empty-list">
<strong>{{ contacts.loading ? '正在加载' : '暂无联系人' }}</strong>
<span>{{ contacts.loading ? '请稍候' : '好友同步完成后会显示在这里' }}</span>
</div>
</div>
<div v-if="addingContact" class="modal-backdrop" @click.self="closeAddContact">
<form class="modal-panel" @submit.prevent="submitAddContact">
<header>
<strong>添加联系人</strong>
<button type="button" class="icon-button" title="关闭" @click="closeAddContact">×</button>
</header>
<label>
<span>用户ID / 手机号 / 用户名</span>
<input v-model.trim="addKeyword" autocomplete="off" placeholder="请输入要添加的联系人">
</label>
<p v-if="contacts.actionError" class="form-error">{{ contacts.actionError }}</p>
<p v-if="contacts.actionInfo" class="form-info">{{ contacts.actionInfo }}</p>
<div class="modal-actions">
<button type="button" class="ghost-inline-action" @click="closeAddContact">取消</button>
<button class="primary-inline-action" :disabled="contacts.loading || !addKeyword">
{{ contacts.loading ? '添加中...' : '添加' }}
</button>
</div>
</form>
</div>
</aside>
</template>
<script setup>
import { computed, ref } from 'vue';
import { useContactStore } from '@/stores/contacts';
const contacts = useContactStore();
const keyword = ref('');
const addingContact = ref(false);
const addKeyword = ref('');
const filteredContacts = computed(() => {
const value = keyword.value.toLowerCase();
if (!value) {
return contacts.contacts;
}
return contacts.contacts.filter(item => {
return item.title.toLowerCase().includes(value)
|| item.subtitle.toLowerCase().includes(value)
|| item.uid.toLowerCase().includes(value);
});
});
function openAddContact() {
contacts.actionError = '';
contacts.actionInfo = '';
addKeyword.value = '';
addingContact.value = true;
}
function closeAddContact() {
addingContact.value = false;
}
async function submitAddContact() {
const added = await contacts.addContact(addKeyword.value);
if (added) {
window.setTimeout(() => {
addingContact.value = false;
}, 500);
}
}
</script>
+525
View File
@@ -0,0 +1,525 @@
<template>
<aside class="conversation-list">
<header class="list-header">
<div>
<h2>消息</h2>
<p>{{ conversations.totalUnread }} 条未读</p>
</div>
<div class="header-actions action-menu-anchor">
<button class="icon-button" title="新建会话" @click.stop="toggleActionMenu">+</button>
<div v-if="actionMenuOpen" class="quick-action-menu" @click.stop>
<button type="button" @click="openAction('addContact')">添加联系人</button>
<button type="button" @click="openAction('createGroup')">新建群组</button>
<button type="button" @click="openAction('joinGroup')">加入群组</button>
<button type="button" @click="openAction('broadcast')">群发助手</button>
<button type="button" @click="openAction('createChannel')">创建频道/公告群</button>
</div>
</div>
</header>
<div class="search-box">
<input v-model.trim="searchKeyword" placeholder="搜索会话、联系人">
</div>
<div class="sidebar-scroll">
<button
v-for="item in filteredConversationItems"
:key="item.resultKey"
class="conversation-item"
:class="{ active: isListItemActive(item) }"
@click="selectListItem(item)"
>
<img v-if="item.portrait" class="conversation-avatar image-avatar" :src="item.portrait" alt="">
<span v-else class="conversation-avatar">{{ item.avatarText }}</span>
<span class="conversation-copy">
<strong>{{ item.title }}</strong>
<small>{{ item.subtitle }}</small>
</span>
<span
v-if="Number(item.unread || 0) > 0"
class="unread-badge"
:aria-label="`${item.unread} 条未读消息`"
>
{{ formatUnread(item.unread) }}
</span>
</button>
<div v-if="!filteredConversationItems.length && searchKeyword" class="empty-list">
<strong>没有匹配结果</strong>
<span>换个关键词再试试</span>
</div>
<div v-else-if="!conversations.conversations.length && !conversations.usingDemoData" class="empty-list">
<strong>暂无会话</strong>
<span>收到或发起消息后会出现在这里</span>
</div>
</div>
<div v-if="activeAction" class="modal-backdrop" @click.self="closeActionModal">
<form class="modal-panel conversation-action-panel" @submit.prevent="submitAction">
<header>
<strong>{{ actionTitle }}</strong>
<button type="button" class="icon-button" title="关闭" @click="closeActionModal">×</button>
</header>
<template v-if="activeAction === 'addContact'">
<label>
<span>用户ID / 手机号 / 用户名</span>
<input v-model.trim="addKeyword" autocomplete="off" placeholder="请输入要添加的联系人">
</label>
</template>
<template v-else-if="activeAction === 'createGroup'">
<label>
<span>群组名称</span>
<input v-model.trim="groupName" maxlength="64" autocomplete="off" placeholder="请输入群组名称">
</label>
<label>
<span>群说明</span>
<textarea v-model.trim="groupAnnouncement" maxlength="1000" rows="3" placeholder="可选"></textarea>
</label>
<label>
<span>欢迎词</span>
<textarea v-model.trim="groupWelcomeText" maxlength="1000" rows="3" placeholder="群组创建后自动发送"></textarea>
</label>
<ContactPicker
:contacts="filteredContacts"
:selected-ids="selectedContactIds"
:keyword="contactKeyword"
empty-text="暂无可选联系人"
@update:keyword="contactKeyword = $event"
@toggle="toggleContact"
/>
</template>
<template v-else-if="activeAction === 'joinGroup'">
<label>
<span>群组ID / 群名称</span>
<input v-model.trim="joinGroupKey" autocomplete="off" placeholder="请输入群组ID或群名称">
</label>
</template>
<template v-else-if="activeAction === 'broadcast'">
<label>
<span>群发内容</span>
<textarea v-model.trim="broadcastText" maxlength="2000" rows="4" placeholder="输入要群发的文字"></textarea>
</label>
<ContactPicker
:contacts="filteredContacts"
:selected-ids="selectedContactIds"
:keyword="contactKeyword"
empty-text="暂无可选联系人"
@update:keyword="contactKeyword = $event"
@toggle="toggleContact"
/>
</template>
<template v-else-if="activeAction === 'createChannel'">
<label>
<span>频道/公告群名称</span>
<input v-model.trim="channelName" maxlength="64" autocomplete="off" placeholder="请输入名称">
</label>
<label>
<span>公告说明</span>
<textarea v-model.trim="channelAnnouncement" maxlength="1000" rows="3" placeholder="可选"></textarea>
</label>
<ContactPicker
:contacts="filteredContacts"
:selected-ids="selectedContactIds"
:keyword="contactKeyword"
empty-text="暂无可选联系人"
@update:keyword="contactKeyword = $event"
@toggle="toggleContact"
/>
</template>
<p v-if="modalError" class="form-error">{{ modalError }}</p>
<p v-else-if="modalInfo" class="form-info">{{ modalInfo }}</p>
<div class="modal-actions">
<button type="button" class="ghost-inline-action" @click="closeActionModal">取消</button>
<button class="primary-inline-action" :disabled="actionBusy || submitDisabled">
{{ actionBusy ? '处理中...' : submitLabel }}
</button>
</div>
</form>
</div>
</aside>
</template>
<script setup>
import { computed, defineComponent, h, onBeforeUnmount, onMounted, ref } from 'vue';
import { useConversationStore } from '@/stores/conversations';
import { useContactStore } from '@/stores/contacts';
const conversations = useConversationStore();
const contacts = useContactStore();
const actionMenuOpen = ref(false);
const activeAction = ref('');
const actionBusy = ref(false);
const searchKeyword = ref('');
const addKeyword = ref('');
const groupName = ref('');
const groupAnnouncement = ref('');
const groupWelcomeText = ref('');
const joinGroupKey = ref('');
const broadcastText = ref('');
const channelName = ref('');
const channelAnnouncement = ref('');
const contactKeyword = ref('');
const selectedContactIds = ref([]);
const actionTitles = {
addContact: '添加联系人',
createGroup: '新建群组',
joinGroup: '加入群组',
broadcast: '群发助手',
createChannel: '创建频道/公告群'
};
const submitLabels = {
addContact: '添加',
createGroup: '创建',
joinGroup: '加入',
broadcast: '发送',
createChannel: '创建'
};
const actionTitle = computed(() => actionTitles[activeAction.value] || '');
const submitLabel = computed(() => submitLabels[activeAction.value] || '确定');
const modalError = computed(() => contacts.actionError || conversations.actionError);
const modalInfo = computed(() => contacts.actionInfo || conversations.actionNotice);
const normalizedSearchKeyword = computed(() => searchKeyword.value.trim().toLowerCase());
const contactsByUid = computed(() => {
const map = new Map();
contacts.contacts.forEach(contact => {
const uid = String(contact.uid || contact.id || '');
if (uid) {
map.set(uid, contact);
}
});
return map;
});
const filteredConversationItems = computed(() => {
const keyword = normalizedSearchKeyword.value;
if (!keyword) {
return conversations.conversations.map(item => conversationResult(item));
}
const conversationResults = conversations.conversations
.filter(item => matchesConversation(item, keyword, contactForConversation(item)))
.map(item => conversationResult(item));
const conversationTargets = new Set(conversations.conversations
.filter(item => item.type === 'single')
.map(item => conversationTarget(item)));
const contactResults = contacts.contacts
.filter(item => !conversationTargets.has(String(item.uid || item.id || '')))
.filter(item => matchesContact(item, keyword))
.map(item => contactResult(item));
return [...conversationResults, ...contactResults];
});
const filteredContacts = computed(() => {
const keyword = contactKeyword.value.toLowerCase();
if (!keyword) {
return contacts.contacts;
}
return contacts.contacts.filter(item => {
return String(item.title || '').toLowerCase().includes(keyword)
|| String(item.subtitle || '').toLowerCase().includes(keyword)
|| String(item.uid || '').toLowerCase().includes(keyword);
});
});
const submitDisabled = computed(() => {
if (activeAction.value === 'addContact') {
return !addKeyword.value;
}
if (activeAction.value === 'createGroup') {
return !groupName.value || !selectedContactIds.value.length;
}
if (activeAction.value === 'joinGroup') {
return !joinGroupKey.value;
}
if (activeAction.value === 'broadcast') {
return !broadcastText.value || !selectedContactIds.value.length;
}
if (activeAction.value === 'createChannel') {
return !channelName.value;
}
return true;
});
onMounted(() => {
contacts.refreshContacts().catch(() => {});
});
const ContactPicker = defineComponent({
name: 'ContactPicker',
props: {
contacts: {
type: Array,
default: () => []
},
selectedIds: {
type: Array,
default: () => []
},
keyword: {
type: String,
default: ''
},
emptyText: {
type: String,
default: '暂无联系人'
}
},
emits: ['toggle', 'update:keyword'],
setup(props, { emit }) {
return () => h('div', { class: 'contact-picker' }, [
h('input', {
value: props.keyword,
placeholder: '搜索联系人',
onInput: event => emit('update:keyword', event.target.value)
}),
h('div', { class: 'contact-picker-list' }, props.contacts.length
? props.contacts.map(contact => h('button', {
key: contact.uid,
type: 'button',
class: ['contact-picker-row', { selected: props.selectedIds.includes(contact.uid) }],
onClick: () => emit('toggle', contact.uid)
}, [
h('span', { class: 'contact-check' }, props.selectedIds.includes(contact.uid) ? '✓' : ''),
contact.portrait
? h('img', { class: 'conversation-avatar image-avatar', src: contact.portrait, alt: '' })
: h('span', { class: 'conversation-avatar' }, contact.avatarText),
h('span', { class: 'contact-picker-copy' }, [
h('strong', contact.title),
h('small', contact.subtitle)
])
]))
: [h('div', { class: 'contact-picker-empty' }, props.emptyText)])
]);
}
});
function searchableValues(...values) {
return values
.flatMap(value => {
if (Array.isArray(value)) {
return value;
}
if (value && typeof value === 'object') {
return Object.values(value);
}
return [value];
})
.map(value => String(value || '').toLowerCase())
.filter(Boolean);
}
function matchesKeyword(values, keyword) {
const compactKeyword = keyword.replace(/[^\p{L}\p{N}]+/gu, '');
return searchableValues(values).some(value => {
if (value.includes(keyword)) {
return true;
}
if (!compactKeyword) {
return false;
}
return value.replace(/[^\p{L}\p{N}]+/gu, '').includes(compactKeyword);
});
}
function conversationTarget(item) {
if (item.type !== 'single') {
return String(item.target || item.raw?.target || item.id || '');
}
return String(item.target || item.raw?.target || item.id.replace(/^single-/, '') || '');
}
function contactForConversation(item) {
const target = conversationTarget(item);
return target ? contactsByUid.value.get(target) || null : null;
}
function matchesConversation(item, keyword, contact = null) {
return matchesKeyword([
item.title,
item.subtitle,
item.target,
item.id,
item.type,
item.raw?.target,
item.raw?.groupId,
item.raw?.group_id,
item.raw?.owner_uid,
item.raw?.name,
item.raw?.displayName,
item.raw?.mobile,
item.raw?.friendAlias,
item.raw?.alias,
contact?.title,
contact?.subtitle,
contact?.uid,
contact?.id,
contact?.alias,
contact?.address,
contact?.rawUser,
contact?.rawFriend
], keyword);
}
function matchesContact(item, keyword) {
return matchesKeyword([
item.title,
item.subtitle,
item.uid,
item.id,
item.alias,
item.address,
item.rawUser,
item.rawFriend
], keyword);
}
function conversationResult(item) {
return {
...item,
resultKind: 'conversation',
resultKey: `conversation-${item.id}`
};
}
function contactResult(contact) {
return {
id: contact.id,
resultKind: 'contact',
resultKey: `contact-${contact.uid || contact.id}`,
contact,
title: contact.title,
subtitle: contact.subtitle,
portrait: contact.portrait,
avatarText: contact.avatarText,
unread: 0
};
}
function isListItemActive(item) {
if (item.resultKind === 'conversation') {
return item.id === conversations.activeId;
}
return `single-${item.contact?.uid || item.id}` === conversations.activeId;
}
function selectListItem(item) {
if (item.resultKind === 'contact') {
contacts.openChat(item.contact);
return;
}
conversations.setActiveConversation(item.id);
}
function clearStoreMessages() {
contacts.actionError = '';
contacts.actionInfo = '';
conversations.actionError = '';
conversations.actionNotice = '';
}
function resetActionFields() {
addKeyword.value = '';
groupName.value = '';
groupAnnouncement.value = '';
groupWelcomeText.value = '';
joinGroupKey.value = '';
broadcastText.value = '';
channelName.value = '';
channelAnnouncement.value = '';
contactKeyword.value = '';
selectedContactIds.value = [];
clearStoreMessages();
}
function toggleActionMenu() {
actionMenuOpen.value = !actionMenuOpen.value;
}
function closeActionMenu() {
actionMenuOpen.value = false;
}
function openAction(action) {
resetActionFields();
activeAction.value = action;
actionMenuOpen.value = false;
if (['createGroup', 'broadcast', 'createChannel'].includes(action)) {
contacts.refreshContacts({ force: true });
}
}
function closeActionModal() {
activeAction.value = '';
actionBusy.value = false;
resetActionFields();
}
function toggleContact(contactId) {
const selected = new Set(selectedContactIds.value);
if (selected.has(contactId)) {
selected.delete(contactId);
} else {
selected.add(contactId);
}
selectedContactIds.value = Array.from(selected);
}
async function submitAction() {
if (submitDisabled.value || actionBusy.value) {
return;
}
actionBusy.value = true;
clearStoreMessages();
try {
let ok = false;
if (activeAction.value === 'addContact') {
ok = await contacts.addContact(addKeyword.value);
if (ok && contacts.activeContact) {
contacts.openChat(contacts.activeContact);
}
} else if (activeAction.value === 'createGroup') {
ok = await conversations.createGroup({
name: groupName.value,
announcement: groupAnnouncement.value,
welcomeText: groupWelcomeText.value,
memberIds: selectedContactIds.value
});
} else if (activeAction.value === 'joinGroup') {
ok = await conversations.joinGroup(joinGroupKey.value);
} else if (activeAction.value === 'broadcast') {
ok = await conversations.broadcastMessage(selectedContactIds.value, broadcastText.value);
} else if (activeAction.value === 'createChannel') {
ok = await conversations.createGroup({
name: channelName.value,
memberIds: selectedContactIds.value,
announcement: channelAnnouncement.value,
channel: true
});
}
if (ok) {
window.setTimeout(closeActionModal, 450);
}
} finally {
actionBusy.value = false;
}
}
document.addEventListener('click', closeActionMenu);
onBeforeUnmount(() => {
document.removeEventListener('click', closeActionMenu);
});
function formatUnread(value) {
const count = Number(value || 0);
return count > 99 ? '99+' : String(count);
}
</script>
+724
View File
@@ -0,0 +1,724 @@
<template>
<aside class="navigation-rail">
<button type="button" class="rail-avatar rail-avatar-button" title="编辑个人资料" @click="openProfileEditor">
<img v-if="session.portrait" :src="session.portrait" alt="">
<span v-else>{{ initials }}</span>
</button>
<button class="rail-button" :class="{ active: navigation.section === 'messages' }" title="消息" @click="navigation.showMessages">💬</button>
<button class="rail-button" :class="{ active: navigation.section === 'contacts' }" title="联系人" @click="showContacts">👥</button>
<button class="rail-button" :class="{ active: navigation.section === 'favorites' }" title="收藏" @click="navigation.showFavorites">☆</button>
<div class="rail-spacer"></div>
<button class="rail-button" title="设置" aria-label="设置" @click="openSettings">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 8.2a3.8 3.8 0 1 1 0 7.6 3.8 3.8 0 0 1 0-7.6Z"></path>
<path d="M19.4 13.6a7.8 7.8 0 0 0 .1-1.6l1.7-1.3-1.7-3-2 .8a8 8 0 0 0-1.4-.8L15.8 5h-3.5l-.3 2.1a8 8 0 0 0-1.5.8l-1.9-.8-1.8 3 1.7 1.3a7.8 7.8 0 0 0 0 1.6l-1.7 1.3 1.8 3 1.9-.8c.5.4 1 .7 1.5.8l.3 2.1h3.5l.3-2.1c.5-.2 1-.5 1.4-.8l2 .8 1.7-3-1.8-1.3Z"></path>
</svg>
</button>
<button class="rail-button rail-logout-button" title="退出登录" aria-label="退出登录" @click="logout">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4.5 20.5V3.5h9.2a1.8 1.8 0 0 1 1.8 1.8v13.4a1.8 1.8 0 0 1-1.8 1.8H4.5Z"></path>
<path d="M9 12h9.5"></path>
<path d="m15.5 8.8 3.2 3.2-3.2 3.2"></path>
<path d="M7.8 20.5V5.8l6.5-2.3"></path>
</svg>
</button>
</aside>
<div v-if="settingsOpen" class="modal-backdrop settings-backdrop" @click.self="closeSettings">
<section class="settings-panel">
<header>
<strong>设置</strong>
<button type="button" class="icon-button" title="关闭" @click="closeSettings">×</button>
</header>
<div class="settings-layout">
<nav class="settings-menu" aria-label="设置菜单">
<button
v-for="item in settingsItems"
:key="item.key"
type="button"
:class="{ active: settingsSection === item.key }"
@click="selectSettingsSection(item.key)"
>
<span>{{ item.label }}</span>
<small v-if="item.key !== 'password'">稍后开放</small>
</button>
</nav>
<form v-if="settingsSection === 'password'" class="settings-password-form" @submit.prevent="savePassword">
<h3>更改密码</h3>
<label>
<span>当前密码</span>
<input v-model="passwordDraft.oldPassword" type="password" autocomplete="current-password">
</label>
<label>
<span>新密码</span>
<input v-model="passwordDraft.newPassword" type="password" autocomplete="new-password">
</label>
<label>
<span>确认新密码</span>
<input v-model="passwordDraft.confirmPassword" type="password" autocomplete="new-password">
</label>
<footer>
<span v-if="settingsNotice" class="inline-save-status">{{ settingsNotice }}</span>
<span v-else-if="settingsError" class="inline-save-status error">{{ settingsError }}</span>
<button type="submit" class="primary-inline-action" :disabled="passwordSaving">
{{ passwordSaving ? '保存中...' : '保存密码' }}
</button>
</footer>
</form>
<section v-else class="settings-placeholder">
<h3>{{ activeSettingsItem?.label || '设置' }}</h3>
<p>该功能稍后开放。</p>
</section>
</div>
</section>
</div>
<div v-if="profileOpen" class="modal-backdrop" @click.self="closeProfileEditor">
<form class="modal-panel profile-edit-panel" @submit.prevent="saveProfile">
<header>
<strong>个人资料</strong>
<button type="button" class="icon-button" title="关闭" @click="closeProfileEditor">×</button>
</header>
<div class="profile-avatar-field">
<button
type="button"
class="profile-avatar-preview"
:class="{ clickable: currentAvatarUrl }"
title="预览头像"
@click="openAvatarPreview"
>
<img v-if="currentAvatarUrl" :src="currentAvatarUrl" alt="">
<span v-else>{{ initials }}</span>
</button>
<button type="button" class="primary-inline-action profile-avatar-change-button" @click="choosePortraitFile">更换头像</button>
<input ref="portraitInput" type="file" accept="image/*" @change="selectPortrait">
</div>
<label>
<span>昵称</span>
<input v-model.trim="profileDraft.displayName" maxlength="64" autocomplete="off">
</label>
<label>
<span>用户ID</span>
<input :value="session.userId" readonly>
</label>
<div class="modal-actions">
<button type="button" class="ghost-inline-action" @click="closeProfileEditor">取消</button>
<span v-if="profileNotice" class="inline-save-status">{{ profileNotice }}</span>
<span v-else-if="profileError" class="inline-save-status error">{{ profileError }}</span>
<button class="primary-inline-action" :disabled="profileSaving || !profileDraft.displayName">
{{ profileSaving ? '保存中...' : '保存' }}
</button>
</div>
</form>
</div>
<div v-if="avatarPreviewViewer.open" class="modal-backdrop avatar-preview-backdrop" @click.self="closeAvatarPreview">
<section class="avatar-preview-dialog">
<header>
<strong>头像预览</strong>
<div class="avatar-preview-actions">
<button type="button" title="缩小" @click="zoomAvatarPreview(1 / 1.16)">−</button>
<button type="button" title="放大" @click="zoomAvatarPreview(1.16)">+</button>
<button type="button" title="还原" @click="resetAvatarPreview">1:1</button>
<button type="button" class="icon-button" title="关闭" @click="closeAvatarPreview">×</button>
</div>
</header>
<div
class="avatar-preview-stage"
:class="{ dragging: avatarPreviewViewer.dragging }"
@wheel.prevent="handleAvatarPreviewWheel"
@pointerdown.prevent="startAvatarPreviewDrag"
@pointermove.prevent="moveAvatarPreviewDrag"
@pointerup.prevent="stopAvatarPreviewDrag"
@pointerleave.prevent="stopAvatarPreviewDrag"
>
<img
:src="avatarPreviewViewer.url"
alt="头像预览"
:style="avatarPreviewStyle"
draggable="false"
>
</div>
</section>
</div>
<div v-if="avatarCropper.open" class="modal-backdrop avatar-cropper-backdrop" @click.self="closeAvatarCropper">
<section class="modal-panel avatar-cropper-panel">
<header>
<strong>头像预裁切</strong>
<div class="avatar-cropper-header-actions">
<button type="button" class="primary-inline-action" :disabled="avatarCropper.busy" @click="choosePortraitFile">上传</button>
<button type="button" class="icon-button" title="关闭" @click="closeAvatarCropper">×</button>
</div>
</header>
<div class="avatar-cropper-layout">
<div
class="avatar-cropper-stage"
:class="{ dragging: avatarCropper.dragging }"
@wheel.prevent="handleCropperWheel"
@pointerdown.prevent="startCropperDrag"
@pointermove.prevent="moveCropperDrag"
@pointerup.prevent="stopCropperDrag"
@pointerleave.prevent="stopCropperDrag"
@dragover.prevent
@drop.prevent="dropPortrait"
>
<canvas ref="cropperCanvas"></canvas>
<div class="avatar-cropper-mask" aria-hidden="true"></div>
<div class="avatar-cropper-box" aria-hidden="true"></div>
<div v-if="!avatarCropper.loaded" class="avatar-cropper-placeholder">图片加载中</div>
</div>
<aside class="avatar-cropper-controls">
<div class="avatar-cropper-tools">
<button type="button" title="左旋90度" @click="rotateCropper(-90)">↶90°</button>
<button type="button" title="右旋90度" @click="rotateCropper(90)">↷90°</button>
<button type="button" title="放大" @click="zoomCropper(1.12)">+</button>
<button type="button" title="缩小" @click="zoomCropper(1 / 1.12)">−</button>
<button type="button" title="重置" @click="resetCropperTransform">重置</button>
</div>
<div class="avatar-cropper-preview">
<canvas ref="cropperPreviewCanvas"></canvas>
</div>
<footer>
<span v-if="avatarCropper.error" class="inline-save-status error">{{ avatarCropper.error }}</span>
<button type="button" class="ghost-inline-action" :disabled="avatarCropper.busy" @click="closeAvatarCropper">取消</button>
<button type="button" class="primary-inline-action" :disabled="avatarCropper.busy || !avatarCropper.loaded" @click="confirmAvatarCrop">
{{ avatarCropper.busy ? '处理中...' : '确认裁切' }}
</button>
</footer>
</aside>
</div>
</section>
</div>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import { useSessionStore } from '@/stores/session';
import { useNavigationStore } from '@/stores/navigation';
import { useContactStore } from '@/stores/contacts';
const session = useSessionStore();
const navigation = useNavigationStore();
const contacts = useContactStore();
const settingsItems = [
{ key: 'notifications', label: '通知设置' },
{ key: 'password', label: '更改密码' },
{ key: 'wallet', label: '钱包管理' },
{ key: 'language', label: '切换语言' },
{ key: 'feedback', label: '问题反馈' }
];
const initials = computed(() => (session.displayName || session.userId || 'Q').slice(0, 1).toUpperCase());
const settingsOpen = ref(false);
const settingsSection = ref('password');
const passwordSaving = ref(false);
const settingsNotice = ref('');
const settingsError = ref('');
const profileOpen = ref(false);
const profileSaving = ref(false);
const profileNotice = ref('');
const profileError = ref('');
const profileFile = ref(null);
const profilePreview = ref('');
const portraitInput = ref(null);
const cropperCanvas = ref(null);
const cropperPreviewCanvas = ref(null);
const profileDraft = ref({
displayName: ''
});
const passwordDraft = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: ''
});
const currentAvatarUrl = computed(() => profilePreview.value || session.portrait || '');
const activeSettingsItem = computed(() => settingsItems.find(item => item.key === settingsSection.value) || settingsItems[0]);
const avatarPreviewViewer = reactive({
open: false,
url: '',
scale: 1,
offsetX: 0,
offsetY: 0,
dragging: false,
dragStartX: 0,
dragStartY: 0,
dragOffsetX: 0,
dragOffsetY: 0
});
const avatarCropper = reactive({
open: false,
loaded: false,
busy: false,
error: '',
objectUrl: '',
fileName: 'avatar.jpg',
image: null,
baseScale: 1,
scale: 1,
rotation: 0,
offsetX: 0,
offsetY: 0,
dragging: false,
dragStartX: 0,
dragStartY: 0,
dragOffsetX: 0,
dragOffsetY: 0
});
const avatarPreviewStyle = computed(() => ({
transform: `translate3d(${avatarPreviewViewer.offsetX}px, ${avatarPreviewViewer.offsetY}px, 0) scale(${avatarPreviewViewer.scale})`
}));
const CROP_OUTPUT_SIZE = 512;
onMounted(() => {
session.refreshProfile();
window.addEventListener('resize', drawAvatarCropper);
});
onBeforeUnmount(() => {
revokeProfilePreview();
closeAvatarCropper();
window.removeEventListener('resize', drawAvatarCropper);
});
function showContacts() {
navigation.showContacts();
contacts.refreshContacts();
}
function clearProfileFeedback() {
profileNotice.value = '';
profileError.value = '';
}
function clearSettingsFeedback() {
settingsNotice.value = '';
settingsError.value = '';
}
function resetPasswordDraft() {
passwordDraft.oldPassword = '';
passwordDraft.newPassword = '';
passwordDraft.confirmPassword = '';
}
function openSettings() {
settingsOpen.value = true;
settingsSection.value = 'password';
clearSettingsFeedback();
}
function closeSettings() {
if (passwordSaving.value) {
return;
}
settingsOpen.value = false;
clearSettingsFeedback();
resetPasswordDraft();
}
function selectSettingsSection(section) {
settingsSection.value = section;
clearSettingsFeedback();
}
function revokeProfilePreview() {
if (profilePreview.value) {
URL.revokeObjectURL(profilePreview.value);
profilePreview.value = '';
}
}
function revokeCropperObjectUrl() {
if (avatarCropper.objectUrl) {
URL.revokeObjectURL(avatarCropper.objectUrl);
avatarCropper.objectUrl = '';
}
}
async function openProfileEditor() {
profileOpen.value = true;
clearProfileFeedback();
profileDraft.value.displayName = session.displayName || session.userId || '';
const profile = await session.refreshProfile();
if (profileOpen.value && profile) {
profileDraft.value.displayName = session.displayName || session.userId || '';
}
}
function closeProfileEditor() {
profileOpen.value = false;
profileFile.value = null;
clearProfileFeedback();
revokeProfilePreview();
closeAvatarPreview();
closeAvatarCropper();
}
function selectPortrait(event) {
const file = event.target.files?.[0] || null;
event.target.value = '';
clearProfileFeedback();
if (file) {
openAvatarCropper(file);
}
}
function choosePortraitFile() {
portraitInput.value?.click();
}
function openAvatarPreview() {
if (!currentAvatarUrl.value) {
return;
}
avatarPreviewViewer.open = true;
avatarPreviewViewer.url = currentAvatarUrl.value;
resetAvatarPreview();
}
function closeAvatarPreview() {
avatarPreviewViewer.open = false;
avatarPreviewViewer.dragging = false;
}
function resetAvatarPreview() {
avatarPreviewViewer.scale = 1;
avatarPreviewViewer.offsetX = 0;
avatarPreviewViewer.offsetY = 0;
}
function zoomAvatarPreview(factor) {
avatarPreviewViewer.scale = Math.max(0.5, Math.min(6, Number((avatarPreviewViewer.scale * factor).toFixed(2))));
if (avatarPreviewViewer.scale <= 1) {
avatarPreviewViewer.offsetX = 0;
avatarPreviewViewer.offsetY = 0;
}
}
function handleAvatarPreviewWheel(event) {
zoomAvatarPreview(event.deltaY < 0 ? 1.12 : 1 / 1.12);
}
function startAvatarPreviewDrag(event) {
if (avatarPreviewViewer.scale <= 1) {
return;
}
avatarPreviewViewer.dragging = true;
avatarPreviewViewer.dragStartX = event.clientX;
avatarPreviewViewer.dragStartY = event.clientY;
avatarPreviewViewer.dragOffsetX = avatarPreviewViewer.offsetX;
avatarPreviewViewer.dragOffsetY = avatarPreviewViewer.offsetY;
event.currentTarget.setPointerCapture?.(event.pointerId);
}
function moveAvatarPreviewDrag(event) {
if (!avatarPreviewViewer.dragging) {
return;
}
avatarPreviewViewer.offsetX = avatarPreviewViewer.dragOffsetX + event.clientX - avatarPreviewViewer.dragStartX;
avatarPreviewViewer.offsetY = avatarPreviewViewer.dragOffsetY + event.clientY - avatarPreviewViewer.dragStartY;
}
function stopAvatarPreviewDrag() {
avatarPreviewViewer.dragging = false;
}
function dropPortrait(event) {
const file = Array.from(event.dataTransfer?.files || []).find(item => item.type.startsWith('image/'));
if (file) {
clearProfileFeedback();
openAvatarCropper(file);
}
}
function cropperStageSize() {
const canvas = cropperCanvas.value;
const rect = canvas?.getBoundingClientRect();
const width = Math.max(280, Math.round(rect?.width || 360));
const height = Math.max(280, Math.round(rect?.height || 360));
return { width, height };
}
function cropperBoxSize() {
const { width, height } = cropperStageSize();
return Math.round(Math.min(width, height) * 0.74);
}
function rotatedImageSize() {
const image = avatarCropper.image;
if (!image) {
return { width: 1, height: 1 };
}
const quarterTurn = Math.abs(avatarCropper.rotation % 180) === 90;
return {
width: quarterTurn ? image.naturalHeight : image.naturalWidth,
height: quarterTurn ? image.naturalWidth : image.naturalHeight
};
}
function clampCropperOffset() {
const cropSize = cropperBoxSize();
const rotated = rotatedImageSize();
const scaledWidth = rotated.width * avatarCropper.scale;
const scaledHeight = rotated.height * avatarCropper.scale;
const maxX = Math.max(0, (scaledWidth - cropSize) / 2);
const maxY = Math.max(0, (scaledHeight - cropSize) / 2);
avatarCropper.offsetX = Math.max(-maxX, Math.min(maxX, avatarCropper.offsetX));
avatarCropper.offsetY = Math.max(-maxY, Math.min(maxY, avatarCropper.offsetY));
}
function updateCropperBaseScale() {
const cropSize = cropperBoxSize();
const rotated = rotatedImageSize();
avatarCropper.baseScale = Math.max(cropSize / rotated.width, cropSize / rotated.height);
avatarCropper.scale = Math.max(avatarCropper.baseScale, avatarCropper.scale || avatarCropper.baseScale);
}
function drawCanvasImage(canvas, width, height, outputScale = 1, cropOnly = false) {
const image = avatarCropper.image;
if (!canvas || !image) {
return;
}
const dpr = cropOnly ? 1 : (window.devicePixelRatio || 1);
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
const context = canvas.getContext('2d');
context.setTransform(dpr, 0, 0, dpr, 0, 0);
context.clearRect(0, 0, width, height);
context.fillStyle = '#eef4f7';
context.fillRect(0, 0, width, height);
context.save();
context.translate(width / 2 + avatarCropper.offsetX * outputScale, height / 2 + avatarCropper.offsetY * outputScale);
context.rotate((avatarCropper.rotation * Math.PI) / 180);
context.scale(avatarCropper.scale * outputScale, avatarCropper.scale * outputScale);
context.drawImage(image, -image.naturalWidth / 2, -image.naturalHeight / 2);
context.restore();
if (!cropOnly) {
const cropSize = cropperBoxSize();
context.save();
context.fillStyle = 'rgba(10, 18, 30, 0.42)';
context.beginPath();
context.rect(0, 0, width, height);
context.rect((width - cropSize) / 2, (height - cropSize) / 2, cropSize, cropSize);
context.fill('evenodd');
context.restore();
}
}
function drawAvatarCropper() {
if (!avatarCropper.open || !avatarCropper.loaded) {
return;
}
clampCropperOffset();
const { width, height } = cropperStageSize();
drawCanvasImage(cropperCanvas.value, width, height);
const previewCanvas = cropperPreviewCanvas.value;
if (previewCanvas) {
drawCanvasImage(previewCanvas, 108, 108, 108 / cropperBoxSize(), true);
}
}
function resetCropperTransform() {
if (!avatarCropper.image) {
return;
}
avatarCropper.rotation = 0;
avatarCropper.offsetX = 0;
avatarCropper.offsetY = 0;
updateCropperBaseScale();
avatarCropper.scale = avatarCropper.baseScale;
drawAvatarCropper();
}
function openAvatarCropper(file) {
if (!file.type.startsWith('image/')) {
profileError.value = '请选择图片文件';
return;
}
if (file.size > 8 * 1024 * 1024) {
profileError.value = '头像图片不能超过8MB';
return;
}
closeAvatarCropper();
avatarCropper.open = true;
avatarCropper.loaded = false;
avatarCropper.error = '';
avatarCropper.fileName = file.name || 'avatar.jpg';
avatarCropper.objectUrl = URL.createObjectURL(file);
const image = new Image();
image.onload = () => {
avatarCropper.image = image;
avatarCropper.loaded = true;
nextTick(resetCropperTransform);
};
image.onerror = () => {
avatarCropper.error = '图片加载失败';
avatarCropper.loaded = false;
};
image.src = avatarCropper.objectUrl;
}
function closeAvatarCropper() {
avatarCropper.open = false;
avatarCropper.loaded = false;
avatarCropper.busy = false;
avatarCropper.error = '';
avatarCropper.image = null;
avatarCropper.dragging = false;
revokeCropperObjectUrl();
}
function zoomCropper(factor) {
if (!avatarCropper.loaded) {
return;
}
updateCropperBaseScale();
avatarCropper.scale = Math.max(avatarCropper.baseScale, Math.min(avatarCropper.baseScale * 6, avatarCropper.scale * factor));
drawAvatarCropper();
}
function rotateCropper(degrees) {
if (!avatarCropper.loaded) {
return;
}
avatarCropper.rotation = (avatarCropper.rotation + degrees + 360) % 360;
updateCropperBaseScale();
clampCropperOffset();
drawAvatarCropper();
}
function handleCropperWheel(event) {
zoomCropper(event.deltaY < 0 ? 1.08 : 1 / 1.08);
}
function startCropperDrag(event) {
if (!avatarCropper.loaded) {
return;
}
avatarCropper.dragging = true;
avatarCropper.dragStartX = event.clientX;
avatarCropper.dragStartY = event.clientY;
avatarCropper.dragOffsetX = avatarCropper.offsetX;
avatarCropper.dragOffsetY = avatarCropper.offsetY;
event.currentTarget.setPointerCapture?.(event.pointerId);
}
function moveCropperDrag(event) {
if (!avatarCropper.dragging) {
return;
}
avatarCropper.offsetX = avatarCropper.dragOffsetX + event.clientX - avatarCropper.dragStartX;
avatarCropper.offsetY = avatarCropper.dragOffsetY + event.clientY - avatarCropper.dragStartY;
drawAvatarCropper();
}
function stopCropperDrag() {
avatarCropper.dragging = false;
}
function canvasToBlob(canvas, type = 'image/jpeg', quality = 0.9) {
return new Promise((resolve, reject) => {
canvas.toBlob(blob => {
if (blob) {
resolve(blob);
} else {
reject(new Error('头像裁切失败'));
}
}, type, quality);
});
}
async function confirmAvatarCrop() {
if (!avatarCropper.loaded || avatarCropper.busy) {
return;
}
avatarCropper.busy = true;
avatarCropper.error = '';
try {
const outputCanvas = document.createElement('canvas');
drawCanvasImage(outputCanvas, CROP_OUTPUT_SIZE, CROP_OUTPUT_SIZE, CROP_OUTPUT_SIZE / cropperBoxSize(), true);
const blob = await canvasToBlob(outputCanvas);
const fileName = avatarCropper.fileName.replace(/\.[^.]+$/, '') || 'avatar';
const croppedFile = new File([blob], `${fileName}.jpg`, { type: 'image/jpeg' });
profileFile.value = croppedFile;
revokeProfilePreview();
profilePreview.value = URL.createObjectURL(croppedFile);
closeAvatarCropper();
} catch (error) {
avatarCropper.error = error.message || '头像裁切失败';
} finally {
avatarCropper.busy = false;
}
}
async function saveProfile() {
if (profileSaving.value || !profileDraft.value.displayName) {
return;
}
clearProfileFeedback();
profileSaving.value = true;
try {
await session.saveProfile({
displayName: profileDraft.value.displayName,
portraitFile: profileFile.value
});
profileFile.value = null;
revokeProfilePreview();
profileNotice.value = '资料已保存';
contacts.refreshContacts({ force: true });
} catch (error) {
profileError.value = error.message || '资料保存失败';
} finally {
profileSaving.value = false;
}
}
async function savePassword() {
if (passwordSaving.value) {
return;
}
clearSettingsFeedback();
const oldPassword = passwordDraft.oldPassword;
const newPassword = passwordDraft.newPassword;
const confirmPassword = passwordDraft.confirmPassword;
if (!oldPassword || !newPassword || !confirmPassword) {
settingsError.value = '请填写完整密码';
return;
}
if (newPassword !== confirmPassword) {
settingsError.value = '两次输入的新密码不一致';
return;
}
if (oldPassword === newPassword) {
settingsError.value = '新密码不能和当前密码相同';
return;
}
passwordSaving.value = true;
try {
await session.changePassword({ oldPassword, newPassword });
resetPasswordDraft();
settingsNotice.value = '密码已更新';
} catch (error) {
settingsError.value = error.message || '密码修改失败';
} finally {
passwordSaving.value = false;
}
}
function logout() {
closeSettings();
contacts.clearContacts();
session.logout();
}
</script>
+34
View File
@@ -0,0 +1,34 @@
const currentHost = window.location.hostname || '127.0.0.1';
const runtimeConfig = window.JC_WEB_IM_CONFIG || {};
const appServerOrigin = `${window.location.protocol}//${currentHost}:18888`;
const adminServerOrigin = `${window.location.protocol}//${currentHost}:8024`;
export const appConfig = {
appName: 'Web IM',
adminServer: runtimeConfig.adminServer || process.env.VUE_APP_ADMIN_SERVER || adminServerOrigin,
appServer: runtimeConfig.appServer || process.env.VUE_APP_APP_SERVER || appServerOrigin,
platformConfigApi: runtimeConfig.platformConfigApi || process.env.VUE_APP_PLATFORM_CONFIG_API || (process.env.NODE_ENV === 'development' ? '/local-api/platform_config.php' : '/api/platform_config.php'),
registerConfigApi: runtimeConfig.registerConfigApi || process.env.VUE_APP_REGISTER_CONFIG_API || `${adminServerOrigin}/api/register-config`,
registerPhoneExistsApi: runtimeConfig.registerPhoneExistsApi || process.env.VUE_APP_REGISTER_PHONE_EXISTS_API || `${adminServerOrigin}/api/register-phone-exists`,
contactsApi: runtimeConfig.contactsApi || process.env.VUE_APP_CONTACTS_API || (process.env.NODE_ENV === 'development' ? '/local-api/contacts.php' : '/api/contacts.php'),
updateAliasApi: runtimeConfig.updateAliasApi || process.env.VUE_APP_UPDATE_ALIAS_API || (process.env.NODE_ENV === 'development' ? '/local-api/update_alias.php' : '/api/update_alias.php'),
addContactApi: runtimeConfig.addContactApi || process.env.VUE_APP_ADD_CONTACT_API || (process.env.NODE_ENV === 'development' ? '/local-api/add_contact.php' : '/api/add_contact.php'),
conversationActionsApi: runtimeConfig.conversationActionsApi || process.env.VUE_APP_CONVERSATION_ACTIONS_API || (process.env.NODE_ENV === 'development' ? '/local-api/conversation_actions.php' : '/api/conversation_actions.php'),
sendMessageApi: runtimeConfig.sendMessageApi || process.env.VUE_APP_SEND_MESSAGE_API || (process.env.NODE_ENV === 'development' ? '/local-api/send_message.php' : '/api/send_message.php'),
messageActionApi: runtimeConfig.messageActionApi || process.env.VUE_APP_MESSAGE_ACTION_API || (process.env.NODE_ENV === 'development' ? '/local-api/message_action.php' : '/api/message_action.php'),
messageDetailApi: runtimeConfig.messageDetailApi || process.env.VUE_APP_MESSAGE_DETAIL_API || (process.env.NODE_ENV === 'development' ? '/local-api/message_detail.php' : '/api/message_detail.php'),
messagesApi: runtimeConfig.messagesApi || process.env.VUE_APP_MESSAGES_API || (process.env.NODE_ENV === 'development' ? '/local-api/messages.php' : '/api/messages.php'),
profileApi: runtimeConfig.profileApi || process.env.VUE_APP_PROFILE_API || (process.env.NODE_ENV === 'development' ? '/local-api/profile.php' : '/api/profile.php'),
changePasswordApi: runtimeConfig.changePasswordApi || process.env.VUE_APP_CHANGE_PASSWORD_API || `${appServerOrigin}/change_pwd`,
resetPasswordApi: runtimeConfig.resetPasswordApi || process.env.VUE_APP_RESET_PASSWORD_API || `${appServerOrigin}/reset_pwd`,
imServerHost: runtimeConfig.imServerHost || process.env.VUE_APP_IM_SERVER_HOST || currentHost,
imRoutePort: Number(runtimeConfig.imRoutePort || process.env.VUE_APP_IM_ROUTE_PORT || 18000),
useWss: runtimeConfig.useWss ?? process.env.VUE_APP_USE_WSS === 'true',
wsPath: runtimeConfig.wsPath || process.env.VUE_APP_WS_PATH || '/ws',
wsUrl: runtimeConfig.wsUrl || process.env.VUE_APP_WS_URL || '',
sdkMode: runtimeConfig.sdkMode || process.env.VUE_APP_IM_MODE || 'http',
syncIntervalMs: Number(runtimeConfig.syncIntervalMs || process.env.VUE_APP_SYNC_INTERVAL_MS || 300),
platform: 5,
clientIdStorageKey: 'qmt-web-im-client-id',
authTokenStoragePrefix: 'qmt-web-im-auth-token-'
};
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import './styles/base.css';
createApp(App).use(createPinia()).mount('#app');
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
import { appConfig } from '@/config';
import { HttpImClient } from './httpImClient';
import { LongConnectionImClient } from './longConnectionImClient';
class UnsupportedWildfireImClient {
constructor() {
throw new Error('当前本地社区版 Web 客户端未启用 WildFire Web SDK');
}
}
export const imClient = appConfig.sdkMode === 'wildfire'
? new UnsupportedWildfireImClient()
: appConfig.sdkMode === 'custom-ws' || appConfig.sdkMode === 'long'
? new LongConnectionImClient()
: new HttpImClient();
export { HttpImClient, LongConnectionImClient, UnsupportedWildfireImClient as WildfireImClient };
+236
View File
@@ -0,0 +1,236 @@
import { appConfig } from '@/config';
import { HttpImClient } from './httpImClient';
const HEARTBEAT_INTERVAL_MS = 25000;
const RECONNECT_MIN_MS = 1000;
const RECONNECT_MAX_MS = 15000;
function buildWsUrl() {
if (appConfig.wsUrl) {
return appConfig.wsUrl;
}
const protocol = appConfig.useWss ? 'wss' : 'ws';
const host = appConfig.imServerHost || window.location.hostname;
const port = appConfig.imRoutePort ? `:${appConfig.imRoutePort}` : '';
const path = appConfig.wsPath?.startsWith('/') ? appConfig.wsPath : `/${appConfig.wsPath || 'ws'}`;
return `${protocol}://${host}${port}${path}`;
}
function normalizeConversationId(payload) {
const target = payload.target || payload.to || payload.receiver || payload.conversationTarget || payload.from || payload.sender;
return payload.conversationId || (target ? `single-${target}` : '');
}
function normalizeIncomingMessage(payload, currentUserId) {
const item = payload.message || payload;
const from = item.from || item.sender || item.fromUser || '';
const target = item.target || item.to || item.receiver || '';
const direction = from && String(from) === String(currentUserId) ? 'out' : 'in';
const conversationId = item.conversationId || normalizeConversationId({
...item,
target: direction === 'out' ? target : from
});
if (!conversationId) {
return null;
}
const status = item.status || item.deliveryStatus || 'sent';
return {
id: item.id || item.messageId || item.messageUid || `${Date.now()}${Math.random().toString(16).slice(2)}`,
messageUid: item.messageUid || item.uid || item.messageId || '',
conversationId,
from,
target,
direction,
type: item.type || 'text',
text: item.text || item.content || item.searchableContent || '',
status,
deliveryStatus: item.deliveryStatus || (direction === 'out' ? status : ''),
timestamp: Number(item.timestamp || item.serverTimestamp || Date.now()),
displayTimestamp: Number(item.displayTimestamp || item.timestamp || item.serverTimestamp || Date.now()),
raw: item
};
}
export class LongConnectionImClient extends HttpImClient {
constructor() {
super();
this.socket = null;
this.wsUrl = buildWsUrl();
this.heartbeatTimer = null;
this.reconnectTimer = null;
this.reconnectAttempts = 0;
this.manualClose = false;
}
getSdkVersion() {
return 'JC-LC/0.1';
}
shouldUseMessagePolling() {
return !this.connected;
}
async ensureSdk() {
if (typeof WebSocket === 'undefined') {
throw new Error('当前浏览器不支持 WebSocket 长连接');
}
return true;
}
connect(userId, token) {
this.currentUser = { userId, token };
this.manualClose = false;
this.openSocket();
}
disconnect() {
this.manualClose = true;
this.clearReconnectTimer();
this.clearHeartbeatTimer();
if (this.socket) {
this.socket.close();
this.socket = null;
}
super.disconnect();
}
openSocket() {
if (this.socket?.readyState === WebSocket.OPEN) {
this.sendAuthFrame();
return;
}
if (this.socket?.readyState === WebSocket.CONNECTING) {
return;
}
this.emit('connectionStatusChanged', { connected: false, statusText: '长连接连接中' });
try {
this.socket = new WebSocket(this.wsUrl);
this.socket.binaryType = 'arraybuffer';
} catch (error) {
this.emit('connectionStatusChanged', { connected: false, statusText: '长连接创建失败,使用HTTP补偿' });
this.scheduleReconnect();
return;
}
this.socket.onopen = () => {
this.connected = true;
this.reconnectAttempts = 0;
this.emit('connectionStatusChanged', { connected: true, statusText: '长连接已连接' });
this.sendAuthFrame();
this.startHeartbeat();
this.refreshConversationList();
};
this.socket.onmessage = event => {
this.handleFrame(event.data);
};
this.socket.onerror = () => {
this.emit('connectionStatusChanged', { connected: false, statusText: '长连接异常,使用HTTP补偿' });
};
this.socket.onclose = () => {
this.connected = false;
this.clearHeartbeatTimer();
this.socket = null;
if (this.manualClose) {
return;
}
this.emit('connectionStatusChanged', { connected: false, statusText: '长连接重连中' });
this.scheduleReconnect();
};
}
sendAuthFrame() {
this.sendFrame({
type: 'auth',
userId: this.currentUser?.userId || '',
token: this.currentUser?.token || '',
clientId: this.getClientId(),
platform: appConfig.platform,
timestamp: Date.now()
});
}
sendFrame(frame) {
if (this.socket?.readyState !== WebSocket.OPEN) {
return false;
}
this.socket.send(JSON.stringify(frame));
return true;
}
handleFrame(rawData) {
if (rawData instanceof ArrayBuffer) {
this.emit('rawFrameReceived', rawData);
return;
}
let payload;
try {
payload = typeof rawData === 'string' ? JSON.parse(rawData) : rawData;
} catch (error) {
return;
}
const type = payload.type || payload.event || payload.cmd || '';
if (type === 'pong' || type === 'auth_ok' || type === 'connected') {
return;
}
if (type === 'contactsChanged') {
this.emit('contactsChanged', payload.contacts || []);
return;
}
if (type === 'conversationsChanged') {
this.emit('conversationsChanged', payload.conversations || []);
return;
}
if (type === 'messages' && Array.isArray(payload.messages)) {
payload.messages
.map(message => normalizeIncomingMessage(message, this.currentUser?.userId))
.filter(Boolean)
.forEach(message => this.emit('messageReceived', message));
return;
}
const message = normalizeIncomingMessage(payload, this.currentUser?.userId);
if (message) {
this.emit(message.direction === 'out' ? 'messageStatusChanged' : 'messageReceived', message);
}
}
startHeartbeat() {
this.clearHeartbeatTimer();
this.heartbeatTimer = window.setInterval(() => {
this.sendFrame({ type: 'ping', timestamp: Date.now() });
}, HEARTBEAT_INTERVAL_MS);
}
clearHeartbeatTimer() {
if (this.heartbeatTimer) {
window.clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
scheduleReconnect() {
this.clearReconnectTimer();
const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_MIN_MS * 2 ** this.reconnectAttempts);
this.reconnectAttempts += 1;
this.reconnectTimer = window.setTimeout(() => {
this.openSocket();
}, delay);
}
clearReconnectTimer() {
if (this.reconnectTimer) {
window.clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
}
+233
View File
@@ -0,0 +1,233 @@
import { defineStore } from 'pinia';
import { imClient } from '@/services/imClient';
import { useConversationStore } from '@/stores/conversations';
import { useNavigationStore } from '@/stores/navigation';
import { useSessionStore } from '@/stores/session';
const CONTACT_CACHE_MS = 15000;
function normalizeContacts(contacts) {
const unique = new Map();
contacts.forEach(contact => {
const uid = String(contact.uid || contact.id || '').trim();
if (!uid) {
return;
}
const normalized = {
...contact,
id: String(contact.id || uid),
uid
};
unique.set(uid, {
...(unique.get(uid) || {}),
...normalized
});
});
return Array.from(unique.values()).sort((a, b) => a.title.localeCompare(b.title, 'zh-CN'));
}
export const useContactStore = defineStore('contacts', {
state: () => ({
contacts: [],
activeId: '',
loading: false,
eventsBound: false,
refreshSerial: 0,
refreshPromise: null,
loadedForUserId: '',
loadedAt: 0,
actionError: '',
actionInfo: ''
}),
getters: {
activeContact(state) {
return state.contacts.find(item => item.id === state.activeId) || state.contacts[0] || null;
}
},
actions: {
bindImEvents() {
if (this.eventsBound) {
return;
}
this.eventsBound = true;
imClient.on('contactsChanged', contacts => {
if (!contacts.length && this.contacts.length) {
return;
}
this.contacts = normalizeContacts(contacts);
if (!this.contacts.find(item => item.id === this.activeId)) {
this.activeId = this.contacts[0]?.id || '';
}
});
},
async refreshContacts(options = {}) {
this.bindImEvents();
const session = useSessionStore();
const requestUserId = session.userId;
const force = Boolean(options.force);
if (!requestUserId) {
this.clearContacts();
return [];
}
if (!force
&& this.contacts.length
&& this.loadedForUserId === requestUserId
&& Date.now() - this.loadedAt < CONTACT_CACHE_MS
) {
return this.contacts;
}
if (this.refreshPromise && !force) {
return this.refreshPromise;
}
const serial = this.refreshSerial + 1;
this.refreshSerial = serial;
this.loading = true;
this.refreshPromise = (async () => {
this.contacts = normalizeContacts(imClient.refreshContactList());
if (!this.contacts.find(item => item.id === this.activeId)) {
this.activeId = this.contacts[0]?.id || '';
}
if (!this.contacts.length || force) {
const serverContacts = await imClient.getServerContactList(requestUserId);
if (serial !== this.refreshSerial || useSessionStore().userId !== requestUserId) {
return this.contacts;
}
this.contacts = normalizeContacts(serverContacts);
this.loadedForUserId = requestUserId;
this.loadedAt = Date.now();
if (!this.contacts.find(item => item.id === this.activeId)) {
this.activeId = this.contacts[0]?.id || '';
}
}
window.setTimeout(() => {
if (serial !== this.refreshSerial || useSessionStore().userId !== requestUserId) {
return;
}
const latest = imClient.getContactList(false);
if (latest.length || !this.contacts.length) {
this.contacts = normalizeContacts(latest);
this.loadedForUserId = requestUserId;
this.loadedAt = Date.now();
if (!this.contacts.find(item => item.id === this.activeId)) {
this.activeId = this.contacts[0]?.id || '';
}
}
}, 1200);
return this.contacts;
})();
try {
return await this.refreshPromise;
} finally {
if (serial === this.refreshSerial) {
this.loading = false;
this.refreshPromise = null;
}
}
},
async addContact(keyword) {
const session = useSessionStore();
const value = String(keyword || '').trim();
this.actionError = '';
this.actionInfo = '';
if (!session.userId) {
this.actionError = '请先登录';
return false;
}
if (!value) {
this.actionError = '请输入用户ID、手机号或用户名';
return false;
}
this.loading = true;
try {
const contact = await imClient.addContact(session.userId, value, '添加联系人');
if (contact) {
this.contacts = normalizeContacts([...this.contacts, contact]);
this.activeId = contact.id;
}
this.actionInfo = '联系人已添加';
await this.refreshContacts({ force: true });
return true;
} catch (error) {
this.actionError = error.message || '添加联系人失败';
return false;
} finally {
this.loading = false;
}
},
async updateContactAlias(contactId, alias) {
const session = useSessionStore();
const targetId = String(contactId || '').trim();
const nextAlias = String(alias || '').trim();
this.actionError = '';
this.actionInfo = '';
if (!session.userId) {
this.actionError = '请先登录';
return false;
}
if (!targetId) {
this.actionError = '缺少联系人ID';
return false;
}
try {
const savedAlias = await imClient.updateContactAlias(session.userId, targetId, nextAlias);
this.contacts = normalizeContacts(this.contacts.map(item => {
if (item.uid !== targetId && item.id !== targetId) {
return item;
}
const rawUser = {
...(item.rawUser || {}),
friendAlias: savedAlias
};
const rawFriend = {
...(item.rawFriend || {}),
alias: savedAlias
};
const title = savedAlias || rawUser.displayName || rawUser.name || item.uid;
return {
...item,
title,
avatarText: title.slice(0, 1).toUpperCase(),
alias: savedAlias,
rawFriend,
rawUser
};
}));
this.loadedAt = Date.now();
this.actionInfo = '备注名已修改';
return true;
} catch (error) {
this.actionError = error.message || '备注名修改失败';
return false;
}
},
clearContacts() {
this.refreshSerial += 1;
this.contacts = [];
this.activeId = '';
this.loading = false;
this.refreshPromise = null;
this.loadedForUserId = '';
this.loadedAt = 0;
this.actionError = '';
this.actionInfo = '';
},
setActiveContact(id) {
this.activeId = id;
},
openChat(contact) {
if (!contact) {
return;
}
const conversations = useConversationStore();
const navigation = useNavigationStore();
const conversation = imClient.createSingleConversation(contact.uid, contact);
conversations.upsertConversation(conversation);
conversations.setActiveConversation(conversation.id);
navigation.showMessages();
}
}
});
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
import { defineStore } from 'pinia';
export const useNavigationStore = defineStore('navigation', {
state: () => ({
section: 'messages'
}),
actions: {
showMessages() {
this.section = 'messages';
},
showContacts() {
this.section = 'contacts';
},
showFavorites() {
this.section = 'favorites';
}
}
});
+190
View File
@@ -0,0 +1,190 @@
import { defineStore } from 'pinia';
import { imClient } from '@/services/imClient';
import { appConfig } from '@/config';
const STORAGE_KEY = 'qmt-web-im-session';
function normalizeStoredPortrait(portrait) {
return portrait || '';
}
function nextProfilePortrait(profilePortrait, currentPortrait) {
return normalizeStoredPortrait(profilePortrait) || normalizeStoredPortrait(currentPortrait);
}
export const useSessionStore = defineStore('session', {
state: () => ({
isAuthenticated: false,
connecting: false,
connected: false,
connectionStatusText: '未连接',
userId: '',
token: '',
displayName: '',
portrait: '',
error: '',
sdkVersion: '',
eventsBound: false
}),
actions: {
restore() {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
return;
}
try {
const session = JSON.parse(raw);
if (session.appServer !== appConfig.appServer) {
localStorage.removeItem(STORAGE_KEY);
return;
}
if (session.userId && session.token) {
if (!session.clientId) {
localStorage.removeItem(STORAGE_KEY);
return;
}
this.userId = session.userId;
this.token = session.token;
this.displayName = session.displayName || session.userId;
this.portrait = normalizeStoredPortrait(session.portrait);
this.isAuthenticated = true;
imClient.restoreClientId(session.clientId);
if (session.localOnly) {
this.connectionStatusText = '本地调试登录';
}
this.connect();
this.refreshProfile();
}
} catch (error) {
localStorage.removeItem(STORAGE_KEY);
}
},
async loginWithPassword(mobile, password) {
this.error = '';
this.connecting = true;
try {
const result = await imClient.loginWithPassword(mobile, password);
this.applyLoginResult(result);
await this.connect();
} catch (error) {
this.error = error.message || '登录失败';
} finally {
this.connecting = false;
}
},
async loginWithCode(mobile, code) {
this.error = '';
this.connecting = true;
try {
const result = await imClient.loginWithCode(mobile, code);
this.applyLoginResult(result);
await this.connect();
} catch (error) {
this.error = error.message || '登录失败';
} finally {
this.connecting = false;
}
},
async registerWithPhone(mobile, code) {
this.error = '';
this.connecting = true;
try {
return await imClient.registerWithPhone(mobile, code);
} catch (error) {
this.error = error.message || '注册失败';
return null;
} finally {
this.connecting = false;
}
},
applyLoginResult(result) {
this.userId = result.userId || result.uid || result.id || '';
this.token = result.token || result.imToken || '';
this.displayName = result.displayName || result.userName || result.name || this.userId;
this.portrait = normalizeStoredPortrait(result.portrait);
this.isAuthenticated = Boolean(this.userId && this.token);
if (result.localOnly) {
this.connectionStatusText = '本地调试登录';
}
this.persistSession(Boolean(result.localOnly));
},
persistSession(localOnly = false) {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
userId: this.userId,
token: this.token,
displayName: this.displayName,
portrait: this.portrait,
clientId: imClient.getClientId(),
localOnly,
appServer: appConfig.appServer
}));
},
applyProfile(profile) {
if (!profile) {
return;
}
this.userId = profile.userId || profile.uid || this.userId;
this.displayName = profile.displayName || profile.name || this.displayName || this.userId;
this.portrait = nextProfilePortrait(profile.portrait, this.portrait);
this.persistSession();
},
async refreshProfile() {
if (!this.userId) {
return null;
}
try {
const profile = await imClient.getProfile(this.userId);
this.applyProfile(profile);
return profile;
} catch (error) {
return null;
}
},
async saveProfile({ displayName, portraitFile }) {
if (!this.userId) {
throw new Error('请先登录');
}
const profile = await imClient.updateProfile(this.userId, {
displayName,
portraitFile
});
this.applyProfile(profile);
return profile;
},
async changePassword({ oldPassword, newPassword }) {
if (!this.userId) {
throw new Error('请先登录');
}
await imClient.changePassword({ oldPassword, newPassword });
return true;
},
async resetPasswordWithCode({ mobile, resetCode, newPassword }) {
await imClient.resetPasswordWithCode({ mobile, resetCode, newPassword });
return true;
},
async connect() {
if (!this.userId || !this.token) {
return;
}
await imClient.ensureSdk();
this.sdkVersion = imClient.getSdkVersion();
this.bindImEvents();
await imClient.connect(this.userId, this.token);
},
bindImEvents() {
if (this.eventsBound) {
return;
}
this.eventsBound = true;
imClient.on('connectionStatusChanged', status => {
this.connected = status.connected;
this.connectionStatusText = status.statusText || (status.connected ? '已连接' : '未连接');
});
},
logout() {
imClient.disconnect();
localStorage.removeItem(STORAGE_KEY);
this.$reset();
}
}
});
+2755
View File
File diff suppressed because it is too large Load Diff