parent
e40ce3bab5
commit
dcedd71e90
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
# Vue 3 + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + Vue</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "oc-community-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"element-plus": "^2.11.2",
|
||||
"vue": "^3.5.18",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"vite": "^7.1.2"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<router-view></router-view>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
|
After Width: | Height: | Size: 496 B |
@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<div class="chat-window">
|
||||
<div class="chat-header">
|
||||
<div class="header-left">
|
||||
<img v-if="activeContact" class="header-avatar" :src="activeContact.avatar" alt="avatar" />
|
||||
<div>
|
||||
<div class="chat-title">{{ activeContact?.name || '选择联系人' }}</div>
|
||||
<div class="chat-subtitle" v-if="activeContact">{{ activeContact.status || '在线' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button text :icon="Message" @click="$emit('open-info')">资料</el-button>
|
||||
<el-button text :icon="Phone" @click="$emit('audio-call')">语音</el-button>
|
||||
<el-button text :icon="VideoCamera" @click="$emit('video-call')">视频</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-messages" ref="messagesContainer">
|
||||
<div
|
||||
v-for="(msg, index) in messages"
|
||||
:key="index"
|
||||
class="message-row"
|
||||
:class="{ mine: msg.senderId === selfId }"
|
||||
>
|
||||
<img class="avatar" :src="msg.senderId === selfId ? selfAvatar : activeContact?.avatar" alt="avatar" />
|
||||
<div class="bubble">{{ msg.text }}</div>
|
||||
</div>
|
||||
<div v-if="messages.length === 0" class="empty">开始与 {{ activeContact?.name || '...' }} 聊天吧</div>
|
||||
</div>
|
||||
<div class="chat-input">
|
||||
<el-input
|
||||
v-model="draft"
|
||||
:placeholder="activeContact ? '输入消息...' : '请选择联系人后再发送'"
|
||||
:disabled="!activeContact"
|
||||
@keydown.enter.exact.prevent="handleSend"
|
||||
clearable
|
||||
/>
|
||||
<el-button type="primary" :disabled="!canSend" @click="handleSend">发送</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUpdated, ref, watch, nextTick, computed } from 'vue'
|
||||
import { Message, Phone, VideoCamera } from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
activeContact: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
selfId: {
|
||||
type: String,
|
||||
default: 'me'
|
||||
},
|
||||
selfAvatar: {
|
||||
type: String,
|
||||
default: 'https://avatars.githubusercontent.com/u/9919?v=4'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'send'])
|
||||
|
||||
const messages = ref(props.modelValue)
|
||||
watch(() => props.modelValue, v => (messages.value = v))
|
||||
|
||||
const draft = ref('')
|
||||
const messagesContainer = ref(null)
|
||||
|
||||
const canSend = computed(() => !!props.activeContact && draft.value.trim().length > 0)
|
||||
|
||||
const scrollToBottom = async () => {
|
||||
await nextTick()
|
||||
const el = messagesContainer.value
|
||||
if (el) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(scrollToBottom)
|
||||
onUpdated(scrollToBottom)
|
||||
watch(messages, scrollToBottom, { deep: true })
|
||||
|
||||
const handleSend = () => {
|
||||
if (!canSend.value) return
|
||||
const newMsg = {
|
||||
senderId: props.selfId,
|
||||
text: draft.value.trim(),
|
||||
timestamp: Date.now()
|
||||
}
|
||||
const nextMessages = [...messages.value, newMsg]
|
||||
messages.value = nextMessages
|
||||
emit('update:modelValue', nextMessages)
|
||||
emit('send', { contact: props.activeContact, message: newMsg })
|
||||
draft.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-window {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
.chat-header {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
background: #fff;
|
||||
}
|
||||
.chat-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
.chat-subtitle {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
.empty {
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
color: #909399;
|
||||
}
|
||||
.message-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.message-row.mine {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
.avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.bubble {
|
||||
max-width: 70%;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
border: 1px solid #ebeef5;
|
||||
}
|
||||
.message-row.mine .bubble {
|
||||
background: #409eff;
|
||||
color: #fff;
|
||||
border-color: #409eff;
|
||||
}
|
||||
.chat-input {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border-top: 1px solid #ebeef5;
|
||||
background: #fff;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div class="contacts">
|
||||
<div class="contacts-header">
|
||||
<el-input v-model="keyword" placeholder="搜索联系人" clearable />
|
||||
</div>
|
||||
<el-scrollbar class="contacts-scroll">
|
||||
<div
|
||||
v-for="c in filtered"
|
||||
:key="c.id"
|
||||
class="contact-item"
|
||||
:class="{ active: c.id === modelValue }"
|
||||
@click="select(c.id)"
|
||||
>
|
||||
<img class="avatar" :src="c.avatar" alt="avatar" />
|
||||
<div class="meta">
|
||||
<div class="name">{{ c.name }}</div>
|
||||
<div class="last">{{ c.lastMessage || '...' }}</div>
|
||||
</div>
|
||||
<div class="time" v-if="c.lastTime">{{ formatTime(c.lastTime) }}</div>
|
||||
<el-badge v-if="c.unread" :value="c.unread" class="unread" />
|
||||
</div>
|
||||
<div v-if="filtered.length === 0" class="empty">无匹配联系人</div>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
contacts: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const keyword = ref('')
|
||||
const filtered = computed(() => {
|
||||
const k = keyword.value.trim().toLowerCase()
|
||||
if (!k) return props.contacts
|
||||
return props.contacts.filter(c =>
|
||||
c.name.toLowerCase().includes(k)
|
||||
)
|
||||
})
|
||||
|
||||
const select = id => emit('update:modelValue', id)
|
||||
|
||||
const formatTime = ts => {
|
||||
try {
|
||||
const d = new Date(ts)
|
||||
const h = String(d.getHours()).padStart(2, '0')
|
||||
const m = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${h}:${m}`
|
||||
} catch (e) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.contacts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
border-right: 1px solid #ebeef5;
|
||||
background: #fff;
|
||||
}
|
||||
.contacts-header {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
.contacts-scroll {
|
||||
height: 100%;
|
||||
}
|
||||
.contact-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.contact-item:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
.contact-item.active {
|
||||
background: #ecf5ff;
|
||||
}
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.last {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.time {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
.empty {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div class="home-container">
|
||||
<Navigation />
|
||||
<div class="content">
|
||||
<h2>首页</h2>
|
||||
<el-space>
|
||||
<el-button type="primary" @click="goCreate">去创建OC</el-button>
|
||||
<el-button @click="goCommunity">进入社区</el-button>
|
||||
<el-button type="success" @click="goPhone">打开手机</el-button>
|
||||
</el-space>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import Navigation from './Navigation.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const goCreate = () => router.push('/create-oc')
|
||||
const goCommunity = () => router.push('/community')
|
||||
const goPhone = () => router.push('/phone')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.content {
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<h2>{{ isLoginMode ? '登录' : '注册' }}</h2>
|
||||
<p>{{ isLoginMode ? '欢迎回来!' : '创建新账户' }}</p>
|
||||
</div>
|
||||
|
||||
<el-form :model="form" label-width="80px" class="login-form">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="form.username" placeholder="请输入用户名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input v-model="form.password" type="password" placeholder="请输入密码" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 注册时才显示的字段 -->
|
||||
<template v-if="!isLoginMode">
|
||||
<el-form-item label="昵称">
|
||||
<el-input v-model="form.nickname" placeholder="请输入昵称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="生日">
|
||||
<el-date-picker
|
||||
v-model="form.birthday"
|
||||
type="date"
|
||||
placeholder="选择生日"
|
||||
format="YYYY-MM-DD"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="职业">
|
||||
<el-input v-model="form.occupation" placeholder="请输入职业" />
|
||||
</el-form-item>
|
||||
<el-form-item label="喜好">
|
||||
<el-input v-model="form.hobbies" placeholder="请输入你的喜好" />
|
||||
</el-form-item>
|
||||
<el-form-item label="个人简介">
|
||||
<el-input
|
||||
v-model="form.bio"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="介绍一下自己吧"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<el-form-item>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="isLoginMode ? handleLogin() : handleRegister()"
|
||||
:loading="loading"
|
||||
class="submit-btn"
|
||||
>
|
||||
{{ isLoginMode ? '登录' : '注册' }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="text" @click="toggleMode" class="toggle-btn">
|
||||
{{ isLoginMode ? '还没有账户?点击注册' : '已有账户?点击登录' }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElLoading } from 'element-plus'
|
||||
import { login, register } from '../stores/userStore.js'
|
||||
|
||||
const router = useRouter()
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
nickname: '',
|
||||
birthday: '',
|
||||
occupation: '',
|
||||
hobbies: '',
|
||||
bio: ''
|
||||
})
|
||||
|
||||
const isLoginMode = ref(true)
|
||||
const loading = ref(false)
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!form.username || !form.password) {
|
||||
ElMessage.warning('请输入用户名和密码')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await login({
|
||||
username: form.username,
|
||||
password: form.password
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/community')
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '登录失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!form.username || !form.password) {
|
||||
ElMessage.warning('请输入用户名和密码')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await register({
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
nickname: form.nickname,
|
||||
birthday: form.birthday,
|
||||
occupation: form.occupation,
|
||||
hobbies: form.hobbies,
|
||||
bio: form.bio
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
ElMessage.success('注册成功,已自动登录')
|
||||
// 注册成功后直接跳转到社区页面
|
||||
router.push('/community')
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '注册失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const toggleMode = () => {
|
||||
isLoginMode.value = !isLoginMode.value
|
||||
// 清空表单
|
||||
Object.keys(form).forEach(key => {
|
||||
form[key] = ''
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
padding: 40px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
animation: slideUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.login-header h2 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #303133;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
margin: 0;
|
||||
color: #909399;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
height: 45px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
width: 100%;
|
||||
color: #409eff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.toggle-btn:hover {
|
||||
color: #66b1ff;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.login-card {
|
||||
padding: 30px 20px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.login-header h2 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div class="phone-page">
|
||||
<h2>手机</h2>
|
||||
<el-alert title="这是独立的手机页面(也可在社区中以弹窗形式使用)" type="info" show-icon />
|
||||
<div class="widget">
|
||||
<PhoneWidget v-model="open" />
|
||||
<el-button type="primary" @click="open = true">打开手机</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import PhoneWidget from './PhoneWidget.vue'
|
||||
|
||||
const open = ref(true)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.phone-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
.widget {
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@ -0,0 +1,265 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="modelValueLocal"
|
||||
title="聊天"
|
||||
width="900px"
|
||||
destroy-on-close
|
||||
append-to-body
|
||||
>
|
||||
<div class="chat-layout">
|
||||
<div class="left">
|
||||
<div class="left-header">
|
||||
<img class="me-avatar" :src="selfAvatar" alt="me" />
|
||||
<div class="me-meta">
|
||||
<div class="me-name">我</div>
|
||||
<div class="me-sub">在线</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-tabs v-model="leftTab" class="left-tabs">
|
||||
<el-tab-pane label="聊天" name="chat" />
|
||||
<el-tab-pane label="联系人" name="contacts" />
|
||||
</el-tabs>
|
||||
<ContactsList v-model="activeContactId" :contacts="contacts" v-show="leftTab === 'contacts'" />
|
||||
<div v-show="leftTab === 'chat'" class="chat-list">
|
||||
<div
|
||||
v-for="c in contacts"
|
||||
:key="c.id"
|
||||
class="contact-item"
|
||||
:class="{ active: c.id === activeContactId }"
|
||||
@click="selectContact(c.id)"
|
||||
>
|
||||
<img class="avatar" :src="c.avatar" alt="avatar" />
|
||||
<div class="meta">
|
||||
<div class="name">{{ c.name }}</div>
|
||||
<div class="last">{{ c.lastMessage || '...' }}</div>
|
||||
</div>
|
||||
<div class="time" v-if="c.lastTime">{{ formatTime(c.lastTime) }}</div>
|
||||
<el-badge v-if="c.unread" :value="c.unread" class="unread" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="right">
|
||||
<ChatWindow
|
||||
:active-contact="activeContact"
|
||||
v-model="activeMessages"
|
||||
:self-id="selfId"
|
||||
:self-avatar="selfAvatar"
|
||||
@send="handleSend"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="close">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import ContactsList from './ContactsList.vue'
|
||||
import ChatWindow from './ChatWindow.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const modelValueLocal = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
// mock user and contacts
|
||||
const selfId = 'me'
|
||||
const selfAvatar = 'https://avatars.githubusercontent.com/u/9919?v=4'
|
||||
|
||||
const contacts = ref([
|
||||
{ id: 'u_alice', name: 'Alice', avatar: 'https://i.pravatar.cc/100?img=5', status: '在线', lastMessage: '好的', lastTime: Date.now() - 600000, unread: 0 },
|
||||
{ id: 'u_bob', name: 'Bob', avatar: 'https://i.pravatar.cc/100?img=12', status: '离线', lastMessage: '明天见', lastTime: Date.now() - 3600000, unread: 2 },
|
||||
{ id: 'u_carla', name: 'Carla', avatar: 'https://i.pravatar.cc/100?img=32', status: '在线', lastMessage: '在吗?', lastTime: Date.now() - 120000, unread: 1 }
|
||||
])
|
||||
|
||||
const messagesByContact = ref({
|
||||
u_alice: [
|
||||
{ senderId: 'u_alice', text: '你好~', timestamp: Date.now() - 7200000 },
|
||||
{ senderId: selfId, text: '嗨!', timestamp: Date.now() - 7100000 }
|
||||
],
|
||||
u_bob: [
|
||||
{ senderId: 'u_bob', text: '有空吗', timestamp: Date.now() - 5000000 }
|
||||
],
|
||||
u_carla: []
|
||||
})
|
||||
|
||||
const activeContactId = ref('u_alice')
|
||||
const activeContact = computed(() => contacts.value.find(c => c.id === activeContactId.value) || null)
|
||||
|
||||
const leftTab = ref('chat')
|
||||
|
||||
const activeMessages = computed({
|
||||
get: () => messagesByContact.value[activeContactId.value] || [],
|
||||
set: val => {
|
||||
messagesByContact.value[activeContactId.value] = val
|
||||
}
|
||||
})
|
||||
|
||||
const close = () => {
|
||||
modelValueLocal.value = false
|
||||
}
|
||||
|
||||
const selectContact = (contactId) => {
|
||||
activeContactId.value = contactId
|
||||
}
|
||||
|
||||
const formatTime = (ts) => {
|
||||
try {
|
||||
const d = new Date(ts)
|
||||
const h = String(d.getHours()).padStart(2, '0')
|
||||
const m = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${h}:${m}`
|
||||
} catch (e) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const handleSend = ({ contact, message }) => {
|
||||
const idx = contacts.value.findIndex(c => c.id === contact.id)
|
||||
if (idx !== -1) {
|
||||
contacts.value[idx] = {
|
||||
...contacts.value[idx],
|
||||
lastMessage: message.text,
|
||||
lastTime: message.timestamp
|
||||
}
|
||||
}
|
||||
|
||||
// mock auto-reply
|
||||
setTimeout(() => {
|
||||
const reply = {
|
||||
senderId: contact.id,
|
||||
text: '收到:' + message.text,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
const list = messagesByContact.value[contact.id] || []
|
||||
messagesByContact.value[contact.id] = [...list, reply]
|
||||
|
||||
const cIndex = contacts.value.findIndex(c => c.id === contact.id)
|
||||
if (cIndex !== -1) {
|
||||
const isActive = activeContactId.value === contact.id
|
||||
const nextUnread = isActive ? 0 : (contacts.value[cIndex].unread || 0) + 1
|
||||
contacts.value[cIndex] = {
|
||||
...contacts.value[cIndex],
|
||||
lastMessage: reply.text,
|
||||
lastTime: reply.timestamp,
|
||||
unread: nextUnread
|
||||
}
|
||||
}
|
||||
}, 800)
|
||||
}
|
||||
|
||||
// clear unread when switching to a contact
|
||||
watch(activeContactId, id => {
|
||||
const i = contacts.value.findIndex(c => c.id === id)
|
||||
if (i !== -1) {
|
||||
contacts.value[i] = { ...contacts.value[i], unread: 0 }
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-layout {
|
||||
display: flex;
|
||||
height: 600px;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
.left {
|
||||
width: 280px;
|
||||
height: 100%;
|
||||
}
|
||||
.left-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
.me-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.me-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
.me-sub {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
.left-tabs {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
.right {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat-list {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.contact-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.contact-item:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.contact-item.active {
|
||||
background: #ecf5ff;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.last {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
// 添加这两行来引入 Element Plus
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(ElementPlus) // 使用 Element Plus
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
@ -0,0 +1,79 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
})
|
||||
Loading…
Reference in new issue