Initial Web IM deployment
This commit is contained in:
+27
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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-'
|
||||
};
|
||||
@@ -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
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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';
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user