first commit
This commit is contained in:
@@ -0,0 +1,489 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import api from '../api';
|
||||
import { toast, formatTime } from '../utils';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const otherId = Number(route.params.id);
|
||||
|
||||
const me = ref(JSON.parse(localStorage.getItem('user') || '{}'));
|
||||
const other = ref({ nickname: '对方', building: '' });
|
||||
const messages = ref([]);
|
||||
const input = ref('');
|
||||
const sending = ref(false);
|
||||
const recording = ref(false);
|
||||
const recordingTime = ref(0);
|
||||
|
||||
let timer = null;
|
||||
let recordTimer = null;
|
||||
let mediaRecorder = null;
|
||||
let audioChunks = [];
|
||||
let recordingStart = 0;
|
||||
|
||||
// 加载对方信息(用专用接口,避免搜索接口限制导致找不到)
|
||||
async function loadOther() {
|
||||
try {
|
||||
other.value = await api.get(`/chat/user/${otherId}`);
|
||||
} catch (e) {
|
||||
// 失败不提示,避免打扰
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMessages() {
|
||||
try {
|
||||
const list = await api.get(`/chat/messages/${otherId}`);
|
||||
messages.value = list;
|
||||
scrollToBottom();
|
||||
} catch (e) {
|
||||
// 静默失败,避免轮询时反复弹错误
|
||||
}
|
||||
}
|
||||
|
||||
// 发送文本消息
|
||||
async function sendText() {
|
||||
const content = input.value.trim();
|
||||
if (!content || sending.value) return;
|
||||
await doSend({ receiverId: otherId, content, type: 'text' });
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
// 发送图片
|
||||
function pickImage() {
|
||||
const el = document.createElement('input');
|
||||
el.type = 'file';
|
||||
el.accept = 'image/*';
|
||||
el.onchange = async () => {
|
||||
const file = el.files[0];
|
||||
if (!file) return;
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast('图片不能超过 5MB');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const base64 = await fileToBase64(file);
|
||||
await doSend({ receiverId: otherId, content: base64, type: 'image' });
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
};
|
||||
el.click();
|
||||
}
|
||||
|
||||
// 语音录制
|
||||
async function toggleRecord() {
|
||||
if (recording.value) {
|
||||
// 停止录音
|
||||
stopRecord();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
mediaRecorder = new MediaRecorder(stream);
|
||||
audioChunks = [];
|
||||
mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) audioChunks.push(e.data);
|
||||
};
|
||||
mediaRecorder.onstop = async () => {
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
const blob = new Blob(audioChunks, { type: 'audio/webm' });
|
||||
const duration = recordingTime.value;
|
||||
if (duration < 1) {
|
||||
toast('说话时间太短');
|
||||
recording.value = false;
|
||||
return;
|
||||
}
|
||||
const base64 = await blobToBase64(blob);
|
||||
await doSend({ receiverId: otherId, content: base64, type: 'voice', duration });
|
||||
recording.value = false;
|
||||
};
|
||||
mediaRecorder.start();
|
||||
recording.value = true;
|
||||
recordingTime.value = 0;
|
||||
recordingStart = Date.now();
|
||||
recordTimer = setInterval(() => {
|
||||
recordingTime.value = Math.floor((Date.now() - recordingStart) / 1000);
|
||||
}, 1000);
|
||||
} catch (e) {
|
||||
toast('无法访问麦克风,请检查权限');
|
||||
}
|
||||
}
|
||||
|
||||
function stopRecord() {
|
||||
if (recordTimer) clearInterval(recordTimer);
|
||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||
mediaRecorder.stop();
|
||||
} else {
|
||||
recording.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function doSend(payload) {
|
||||
if (sending.value) return;
|
||||
sending.value = true;
|
||||
try {
|
||||
const msg = await api.post('/chat/send', payload);
|
||||
messages.value.push(msg);
|
||||
scrollToBottom();
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function fileToBase64(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function blobToBase64(blob) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
// 播放语音
|
||||
function playVoice(msg) {
|
||||
const audio = new Audio(msg.content);
|
||||
audio.play().catch(() => toast('语音播放失败'));
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
nextTick(() => {
|
||||
const el = document.querySelector('.msg-list');
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadOther();
|
||||
loadMessages();
|
||||
// 每 2 秒轮询新消息
|
||||
timer = setInterval(loadMessages, 2000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer);
|
||||
if (recordTimer) clearInterval(recordTimer);
|
||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') mediaRecorder.stop();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-page">
|
||||
<!-- 顶部栏 -->
|
||||
<header class="chat-top">
|
||||
<button class="back" @click="router.push('/chat')">‹</button>
|
||||
<div class="avatar">{{ other.nickname?.charAt(0) || '邻' }}</div>
|
||||
<div class="chat-title">
|
||||
<div class="name">{{ other.nickname }}</div>
|
||||
<div class="sub">{{ other.building ? '楼栋:' + other.building : '社区邻居' }}</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 消息列表 -->
|
||||
<div class="msg-list">
|
||||
<div v-if="messages.length === 0" class="empty-tip">打个招呼开始聊天吧~</div>
|
||||
<div
|
||||
v-for="m in messages"
|
||||
:key="m.id"
|
||||
class="msg-row"
|
||||
:class="{ mine: m.senderId === me.id }"
|
||||
>
|
||||
<!-- 对方头像 -->
|
||||
<div v-if="m.senderId !== me.id" class="avatar msg-avatar">
|
||||
{{ other.nickname?.charAt(0) || '邻' }}
|
||||
</div>
|
||||
|
||||
<div class="bubble-wrap">
|
||||
<!-- 文本 -->
|
||||
<div v-if="m.type === 'text'" class="bubble">{{ m.content }}</div>
|
||||
<!-- 图片 -->
|
||||
<div v-else-if="m.type === 'image'" class="bubble bubble-img">
|
||||
<img :src="m.content" alt="图片" @click="window.open(m.content)" />
|
||||
</div>
|
||||
<!-- 语音 -->
|
||||
<div v-else-if="m.type === 'voice'" class="bubble bubble-voice" @click="playVoice(m)">
|
||||
<span class="voice-icon">🔊</span>
|
||||
<span class="voice-duration">{{ m.duration || 1 }}″</span>
|
||||
</div>
|
||||
<div class="msg-time">{{ formatTime(m.createdAt) }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 自己头像 -->
|
||||
<div v-if="m.senderId === me.id" class="avatar msg-avatar">
|
||||
{{ me.nickname?.charAt(0) || '我' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 输入区(QQ 风格) -->
|
||||
<footer class="input-area">
|
||||
<div class="toolbar">
|
||||
<button class="tool-btn" title="发送图片" @click="pickImage">🖼️</button>
|
||||
<button
|
||||
class="tool-btn"
|
||||
:class="{ recording: recording }"
|
||||
:title="recording ? '点击停止录音' : '发送语音'"
|
||||
@click="toggleRecord"
|
||||
>🎤</button>
|
||||
<span v-if="recording" class="recording-hint">正在录音 {{ recordingTime }}″ 点击🎤停止</span>
|
||||
</div>
|
||||
<div class="input-row">
|
||||
<textarea
|
||||
v-model="input"
|
||||
rows="3"
|
||||
placeholder="输入消息..."
|
||||
@keydown.enter.exact.prevent="sendText"
|
||||
></textarea>
|
||||
<button class="send-btn" :disabled="sending || !input.trim()" @click="sendText">发送</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chat-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f2f3f5;
|
||||
}
|
||||
|
||||
/* 顶部栏 */
|
||||
.chat-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: #fff;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.back {
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
color: #333;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.chat-title .name {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.chat-title .sub {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* 消息列表 */
|
||||
.msg-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.empty-tip {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.msg-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.msg-row.mine {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.msg-avatar {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bubble-wrap {
|
||||
max-width: 62%;
|
||||
margin: 0 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.msg-row.mine .bubble-wrap {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
color: #333;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
/* 气泡小三角(QQ 风格) */
|
||||
.bubble::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
left: -6px;
|
||||
border: 6px solid transparent;
|
||||
border-right-color: #fff;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.msg-row.mine .bubble {
|
||||
background: #95ec69;
|
||||
}
|
||||
|
||||
.msg-row.mine .bubble::before {
|
||||
left: auto;
|
||||
right: -6px;
|
||||
border: 6px solid transparent;
|
||||
border-left-color: #95ec69;
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
/* 图片消息 */
|
||||
.bubble-img {
|
||||
padding: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.bubble-img img {
|
||||
max-width: 200px;
|
||||
max-height: 240px;
|
||||
border-radius: 6px;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 语音消息 */
|
||||
.bubble-voice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
min-width: 80px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.voice-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.voice-duration {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.msg-time {
|
||||
font-size: 11px;
|
||||
color: #bbb;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 输入区 */
|
||||
.input-area {
|
||||
background: #fff;
|
||||
border-top: 1px solid #e5e5e5;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 12px 0;
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.tool-btn:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.tool-btn.recording {
|
||||
background: #ffecec;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.recording-hint {
|
||||
font-size: 12px;
|
||||
color: #e74c3c;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
padding: 8px 12px 12px;
|
||||
}
|
||||
|
||||
.input-row textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
padding: 8px 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
border: none;
|
||||
background: #07c160;
|
||||
color: #fff;
|
||||
padding: 8px 22px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.send-btn:disabled {
|
||||
background: #9de6bd;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,233 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import api from '../api';
|
||||
import { toast, formatTime } from '../utils';
|
||||
|
||||
const router = useRouter();
|
||||
const conversations = ref([]);
|
||||
const searchMode = ref(false);
|
||||
const keyword = ref('');
|
||||
const users = ref([]);
|
||||
const totalUnread = ref(0);
|
||||
let timer = null;
|
||||
|
||||
async function loadConversations() {
|
||||
try {
|
||||
conversations.value = await api.get('/chat/conversations');
|
||||
totalUnread.value = conversations.value.reduce((s, c) => s + c.unreadCount, 0);
|
||||
} catch (e) {
|
||||
// 轮询时静默失败,避免反复弹错
|
||||
}
|
||||
}
|
||||
|
||||
async function searchUsers() {
|
||||
try {
|
||||
users.value = await api.get('/chat/users', { params: { keyword: keyword.value } });
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function openChat(userId) {
|
||||
searchMode.value = false;
|
||||
keyword.value = '';
|
||||
router.push({ name: 'chat-detail', params: { id: userId } });
|
||||
}
|
||||
|
||||
function toggleSearch() {
|
||||
searchMode.value = !searchMode.value;
|
||||
if (!searchMode.value) {
|
||||
keyword.value = '';
|
||||
users.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadConversations();
|
||||
// 每 3 秒轮询刷新会话与未读数
|
||||
timer = setInterval(loadConversations, 3000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="chat-header">
|
||||
<div class="page-title">消息</div>
|
||||
<button class="btn btn-ghost btn-sm" @click="toggleSearch">
|
||||
{{ searchMode ? '返回' : '发起聊天' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 搜索用户 -->
|
||||
<div v-if="searchMode" class="search-box">
|
||||
<div class="search-input">
|
||||
<input
|
||||
v-model="keyword"
|
||||
placeholder="搜索邻居昵称或用户名..."
|
||||
@keyup.enter="searchUsers"
|
||||
/>
|
||||
<button class="btn btn-sm" @click="searchUsers">搜索</button>
|
||||
</div>
|
||||
<div class="user-list">
|
||||
<div v-if="users.length === 0" class="empty">输入关键词搜索邻居</div>
|
||||
<div v-for="u in users" :key="u.id" class="user-item" @click="openChat(u.id)">
|
||||
<div class="avatar">{{ u.nickname?.charAt(0) }}</div>
|
||||
<div class="user-info">
|
||||
<div class="user-name">{{ u.nickname }}</div>
|
||||
<div class="user-meta">{{ u.building || '未填写楼栋' }}</div>
|
||||
</div>
|
||||
<span class="start-btn">发消息</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 会话列表 -->
|
||||
<template v-else>
|
||||
<div v-if="conversations.length === 0" class="empty">
|
||||
<p>还没有会话</p>
|
||||
<p class="sub">点击「发起聊天」找到邻居开始聊天吧</p>
|
||||
</div>
|
||||
<div
|
||||
v-for="c in conversations"
|
||||
:key="c.userId"
|
||||
class="card conv"
|
||||
@click="openChat(c.userId)"
|
||||
>
|
||||
<div class="avatar">{{ c.nickname?.charAt(0) }}</div>
|
||||
<div class="conv-info">
|
||||
<div class="conv-name">{{ c.nickname }}</div>
|
||||
<div class="conv-last">{{ c.lastMessage }}</div>
|
||||
</div>
|
||||
<div class="conv-right">
|
||||
<div class="time">{{ formatTime(c.lastTime) }}</div>
|
||||
<span v-if="c.unreadCount > 0" class="unread">{{ c.unreadCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.chat-header .page-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.user-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.user-item:hover {
|
||||
background: #f0f2f1;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.user-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.start-btn {
|
||||
color: var(--primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.conv {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.conv:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.conv-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.conv-name {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.conv-last {
|
||||
font-size: 13px;
|
||||
color: var(--text-light);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.conv-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.unread {
|
||||
background: #e74c3c;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.empty .sub {
|
||||
font-size: 13px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,176 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import api from '../api';
|
||||
import { toast, formatTime } from '../utils';
|
||||
|
||||
const user = ref(JSON.parse(localStorage.getItem('user') || '{}'));
|
||||
const items = ref([]);
|
||||
const showForm = ref(false);
|
||||
const form = ref({ title: '', description: '', price: '', phone: '' });
|
||||
|
||||
const services = [
|
||||
{ icon: '🧹', name: '保洁清洗', desc: '日常保洁、深度清洁、家电清洗' },
|
||||
{ icon: '👶', name: '保姆月嫂', desc: '住家保姆、月嫂、育儿嫂' },
|
||||
{ icon: '👴', name: '养老护理', desc: '老人陪护、康复护理' },
|
||||
{ icon: '🔧', name: '维修安装', desc: '水电维修、家具安装、疏通' }
|
||||
];
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
items.value = await api.get('/housekeeping');
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
if (!form.value.title || !form.value.description) return toast('请填写服务名称和描述');
|
||||
try {
|
||||
await api.post('/housekeeping', form.value);
|
||||
form.value = { title: '', description: '', price: '', phone: '' };
|
||||
showForm.value = false;
|
||||
toast('发布成功');
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(item) {
|
||||
if (!confirm('确定删除吗?')) return;
|
||||
try {
|
||||
await api.delete(`/housekeeping/${item.id}`);
|
||||
toast('删除成功');
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-title">家政服务</div>
|
||||
|
||||
<div class="service-grid">
|
||||
<div v-for="s in services" :key="s.name" class="card service">
|
||||
<div class="service-icon">{{ s.icon }}</div>
|
||||
<div class="service-name">{{ s.name }}</div>
|
||||
<div class="service-desc">{{ s.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn publish-btn" @click="showForm = !showForm">
|
||||
{{ showForm ? '收起' : '发布服务需求' }}
|
||||
</button>
|
||||
|
||||
<div v-if="showForm" class="card form-card">
|
||||
<input v-model="form.title" placeholder="服务名称(如:找保洁阿姨)" />
|
||||
<input v-model="form.price" placeholder="预算/价格(如:30元/小时)" />
|
||||
<textarea v-model="form.description" rows="3" placeholder="服务描述"></textarea>
|
||||
<input v-model="form.phone" placeholder="联系电话(选填)" />
|
||||
<button class="btn" @click="publish">发布</button>
|
||||
</div>
|
||||
|
||||
<div v-if="items.length === 0" class="empty">暂无服务信息</div>
|
||||
<div v-for="item in items" :key="item.id" class="card item">
|
||||
<div class="item-left">
|
||||
<div class="item-title">{{ item.title }}</div>
|
||||
<div class="item-desc">{{ item.description }}</div>
|
||||
<div class="item-meta">
|
||||
<span v-if="item.price" class="price">{{ item.price }}</span>
|
||||
<span v-if="item.phone" class="phone">📞 {{ item.phone }}</span>
|
||||
<span class="time">{{ item.nickname }} · {{ formatTime(item.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="item.user_id === user.id || user.role === 'admin'"
|
||||
class="btn btn-ghost btn-sm"
|
||||
@click="remove(item)"
|
||||
>删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.service-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.service {
|
||||
text-align: center;
|
||||
padding: 20px 12px;
|
||||
}
|
||||
|
||||
.service-icon {
|
||||
font-size: 36px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.service-name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.service-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.publish-btn {
|
||||
width: 100%;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.item-left {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.item-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-light);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.price {
|
||||
color: #e74c3c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.phone {
|
||||
color: var(--primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,340 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import * as signalR from '@microsoft/signalr';
|
||||
import api from '../api';
|
||||
import { toast } from '../utils';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const roomId = Number(route.params.id);
|
||||
const me = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
|
||||
const room = ref({ title: '...' });
|
||||
const localVideo = ref(null);
|
||||
const localStream = ref(null);
|
||||
const viewerCount = ref(0);
|
||||
const danmakuList = ref([]);
|
||||
const danmakuInput = ref('');
|
||||
|
||||
let connection = null;
|
||||
let stream = null;
|
||||
const peers = new Map(); // connectionId -> RTCPeerConnection
|
||||
|
||||
async function loadRoom() {
|
||||
try {
|
||||
room.value = await api.get(`/live/rooms/${roomId}`);
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 开始采集摄像头
|
||||
async function startCamera() {
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { width: 640, height: 480 },
|
||||
audio: true
|
||||
});
|
||||
localStream.value = stream;
|
||||
localVideo.value.srcObject = stream;
|
||||
toast('摄像头已开启');
|
||||
} catch (e) {
|
||||
toast('无法访问摄像头:' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 开始采集屏幕
|
||||
async function startScreen() {
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: true,
|
||||
audio: true
|
||||
});
|
||||
localStream.value = stream;
|
||||
localVideo.value.srcObject = stream;
|
||||
toast('屏幕共享已开启');
|
||||
} catch (e) {
|
||||
toast('无法共享屏幕:' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function stopStream() {
|
||||
if (stream) {
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
stream = null;
|
||||
localStream.value = null;
|
||||
localVideo.value.srcObject = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化 SignalR
|
||||
async function initSignalR() {
|
||||
const token = localStorage.getItem('token');
|
||||
connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl(`/hubs/live?access_token=${token}`)
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
|
||||
connection.on('ViewerJoined', (viewerId) => {
|
||||
// 观众加入:等待观众发 offer
|
||||
});
|
||||
|
||||
connection.on('ViewerLeft', (viewerId) => {
|
||||
const pc = peers.get(viewerId);
|
||||
if (pc) {
|
||||
pc.close();
|
||||
peers.delete(viewerId);
|
||||
}
|
||||
});
|
||||
|
||||
connection.on('ViewerCount', (count) => {
|
||||
viewerCount.value = count;
|
||||
});
|
||||
|
||||
connection.on('ReceiveOffer', async (viewerId, sdp) => {
|
||||
await handleOffer(viewerId, sdp);
|
||||
});
|
||||
|
||||
connection.on('ReceiveIceFromViewer', async (viewerId, candidate) => {
|
||||
const pc = peers.get(viewerId);
|
||||
if (pc) {
|
||||
try {
|
||||
await pc.addIceCandidate(candidate);
|
||||
} catch (e) {}
|
||||
}
|
||||
});
|
||||
|
||||
connection.on('ReceiveDanmaku', (d) => {
|
||||
danmakuList.value.push(d);
|
||||
if (danmakuList.value.length > 50) danmakuList.value.shift();
|
||||
});
|
||||
|
||||
await connection.start();
|
||||
await connection.invoke('HostJoin', roomId);
|
||||
}
|
||||
|
||||
// 处理观众 offer
|
||||
async function handleOffer(viewerId, sdp) {
|
||||
let pc = peers.get(viewerId);
|
||||
if (!pc) {
|
||||
pc = createPeer(viewerId);
|
||||
peers.set(viewerId, pc);
|
||||
}
|
||||
await pc.setRemoteDescription({ type: 'offer', sdp });
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
await connection.invoke('SendAnswer', viewerId, answer.sdp);
|
||||
}
|
||||
|
||||
function createPeer(viewerId) {
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
||||
});
|
||||
|
||||
// 把本地流加入
|
||||
if (stream) {
|
||||
stream.getTracks().forEach(track => pc.addTrack(track, stream));
|
||||
}
|
||||
|
||||
pc.onicecandidate = (e) => {
|
||||
if (e.candidate) {
|
||||
connection.invoke('SendIceToViewer', viewerId, e.candidate);
|
||||
}
|
||||
};
|
||||
|
||||
pc.onconnectionstatechange = () => {
|
||||
if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
|
||||
pc.close();
|
||||
peers.delete(viewerId);
|
||||
}
|
||||
};
|
||||
|
||||
return pc;
|
||||
}
|
||||
|
||||
// 发送弹幕
|
||||
async function sendDanmaku() {
|
||||
const content = danmakuInput.value.trim();
|
||||
if (!content) return;
|
||||
await connection.invoke('SendDanmaku', roomId, content);
|
||||
danmakuList.value.push({ userId: me.id, nickname: me.nickname, content });
|
||||
danmakuInput.value = '';
|
||||
}
|
||||
|
||||
// 关播
|
||||
async function closeLive() {
|
||||
if (!confirm('确定结束直播吗?')) return;
|
||||
try {
|
||||
await api.post(`/live/rooms/${roomId}/close`);
|
||||
toast('直播已结束');
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
stopStream();
|
||||
if (connection) await connection.stop();
|
||||
router.push('/live');
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadRoom();
|
||||
await initSignalR();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopStream();
|
||||
peers.forEach(pc => pc.close());
|
||||
if (connection) connection.stop();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="host-page">
|
||||
<header class="host-top">
|
||||
<button class="back" @click="router.push('/live')">‹</button>
|
||||
<div class="title">
|
||||
<span class="live-dot"></span> {{ room.title }}
|
||||
</div>
|
||||
<span class="viewer-count">👀 {{ viewerCount }}</span>
|
||||
</header>
|
||||
|
||||
<div class="video-area">
|
||||
<video ref="localVideo" autoplay muted playsinline class="local-video"></video>
|
||||
<div v-if="!localStream" class="video-empty">
|
||||
<p>选择一种方式开始直播</p>
|
||||
<div class="source-btns">
|
||||
<button class="btn" @click="startCamera">📷 摄像头</button>
|
||||
<button class="btn" @click="startScreen">🖥️ 屏幕共享</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 弹幕区 -->
|
||||
<div class="danmaku-overlay">
|
||||
<div v-for="(d, i) in danmakuList" :key="i" class="danmaku-item">
|
||||
<span class="dm-name">{{ d.nickname }}:</span>{{ d.content }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="host-footer">
|
||||
<input v-model="danmakuInput" placeholder="发弹幕..." @keyup.enter="sendDanmaku" />
|
||||
<button class="btn btn-sm" @click="sendDanmaku">发送</button>
|
||||
<button class="btn btn-danger btn-sm" @click="closeLive">结束直播</button>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.host-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.host-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.back {
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 28px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.live-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #e74c3c;
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.viewer-count {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.video-area {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
background: #1a1a1a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.local-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.video-empty {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.source-btns {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.danmaku-overlay {
|
||||
position: absolute;
|
||||
top: 80px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
pointer-events: none;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.danmaku-item {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: #fff;
|
||||
padding: 6px 12px;
|
||||
border-radius: 16px;
|
||||
font-size: 13px;
|
||||
align-self: flex-start;
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
.dm-name {
|
||||
color: #ffd666;
|
||||
}
|
||||
|
||||
.host-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.host-footer input {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,177 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import api from '../api';
|
||||
import { toast, formatTime } from '../utils';
|
||||
|
||||
const router = useRouter();
|
||||
const rooms = ref([]);
|
||||
const showForm = ref(false);
|
||||
const form = ref({ title: '', cover: '' });
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
rooms.value = await api.get('/live/rooms');
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function startLive() {
|
||||
if (!form.value.title.trim()) return toast('请填写直播标题');
|
||||
try {
|
||||
const room = await api.post('/live/rooms', form.value);
|
||||
form.value = { title: '', cover: '' };
|
||||
showForm.value = false;
|
||||
toast('直播间已创建');
|
||||
router.push({ name: 'live-host', params: { id: room.id } });
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function enterRoom(r) {
|
||||
if (!r.isLive) {
|
||||
toast('该直播已结束');
|
||||
return;
|
||||
}
|
||||
router.push({ name: 'live-watch', params: { id: r.id } });
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-title">社区直播</div>
|
||||
|
||||
<button class="btn start-btn" @click="showForm = !showForm">
|
||||
{{ showForm ? '收起' : '🎥 我要开播' }}
|
||||
</button>
|
||||
|
||||
<div v-if="showForm" class="card form-card">
|
||||
<input v-model="form.title" placeholder="直播标题(如:小区羽毛球活动)" />
|
||||
<input v-model="form.cover" placeholder="封面图链接(可选)" />
|
||||
<button class="btn" @click="startLive">创建并开播</button>
|
||||
</div>
|
||||
|
||||
<div v-if="rooms.length === 0" class="empty">暂无直播间,快来开播吧~</div>
|
||||
|
||||
<div class="room-grid">
|
||||
<div v-for="r in rooms" :key="r.id" class="card room" @click="enterRoom(r)">
|
||||
<div class="room-cover">
|
||||
<img v-if="r.cover" :src="r.cover" :alt="r.title" @error="$event.target.style.display='none'" />
|
||||
<div v-else class="cover-placeholder">📺</div>
|
||||
<span v-if="r.isLive" class="live-badge">直播中</span>
|
||||
<span v-else class="end-badge">已结束</span>
|
||||
</div>
|
||||
<div class="room-body">
|
||||
<div class="room-title">{{ r.title }}</div>
|
||||
<div class="room-meta">
|
||||
<span class="host">{{ r.hostNickname }}</span>
|
||||
<span class="viewers">👀 {{ r.viewerCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.start-btn {
|
||||
width: 100%;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.room-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.room {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.room:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.room-cover {
|
||||
position: relative;
|
||||
aspect-ratio: 16/10;
|
||||
background: #2c3e50;
|
||||
}
|
||||
|
||||
.room-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.cover-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.live-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
background: #e74c3c;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.end-badge {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.room-body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.room-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.room-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.host {
|
||||
color: var(--primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,259 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import * as signalR from '@microsoft/signalr';
|
||||
import api from '../api';
|
||||
import { toast } from '../utils';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const roomId = Number(route.params.id);
|
||||
const me = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
|
||||
const room = ref({ title: '...' });
|
||||
const remoteVideo = ref(null);
|
||||
const viewerCount = ref(0);
|
||||
const danmakuList = ref([]);
|
||||
const danmakuInput = ref('');
|
||||
const hostConnectionId = ref('');
|
||||
const connecting = ref(true);
|
||||
|
||||
let connection = null;
|
||||
let pc = null;
|
||||
|
||||
async function loadRoom() {
|
||||
try {
|
||||
room.value = await api.get(`/live/rooms/${roomId}`);
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function initSignalR() {
|
||||
const token = localStorage.getItem('token');
|
||||
connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl(`/hubs/live?access_token=${token}`)
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
|
||||
connection.on('ViewerCount', (count) => {
|
||||
viewerCount.value = count;
|
||||
});
|
||||
|
||||
connection.on('ReceiveDanmaku', (d) => {
|
||||
danmakuList.value.push(d);
|
||||
if (danmakuList.value.length > 50) danmakuList.value.shift();
|
||||
});
|
||||
|
||||
connection.on('ReceiveAnswer', async (sdp) => {
|
||||
await pc.setRemoteDescription({ type: 'answer', sdp });
|
||||
});
|
||||
|
||||
connection.on('ReceiveIce', async (candidate) => {
|
||||
try {
|
||||
await pc.addIceCandidate(candidate);
|
||||
} catch (e) {}
|
||||
});
|
||||
|
||||
// 收到主播的 connectionId 后,创建 peer 并发起 offer
|
||||
connection.on('HostReady', async (hostId) => {
|
||||
hostConnectionId.value = hostId;
|
||||
await createPeerAndOffer(hostId);
|
||||
});
|
||||
|
||||
await connection.start();
|
||||
await connection.invoke('JoinRoom', roomId);
|
||||
}
|
||||
|
||||
async function createPeerAndOffer(hostId) {
|
||||
pc = new RTCPeerConnection({
|
||||
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
||||
});
|
||||
|
||||
pc.ontrack = (e) => {
|
||||
if (remoteVideo.value) {
|
||||
remoteVideo.value.srcObject = e.streams[0];
|
||||
connecting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
pc.onicecandidate = (e) => {
|
||||
if (e.candidate) {
|
||||
connection.invoke('SendIceToHost', hostId, e.candidate);
|
||||
}
|
||||
};
|
||||
|
||||
pc.onconnectionstatechange = () => {
|
||||
if (pc.connectionState === 'connected') connecting.value = false;
|
||||
};
|
||||
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
await connection.invoke('SendOffer', hostId, offer.sdp);
|
||||
}
|
||||
|
||||
// 发送弹幕
|
||||
async function sendDanmaku() {
|
||||
const content = danmakuInput.value.trim();
|
||||
if (!content) return;
|
||||
await connection.invoke('SendDanmaku', roomId, content);
|
||||
danmakuList.value.push({ userId: me.id, nickname: me.nickname, content });
|
||||
danmakuInput.value = '';
|
||||
}
|
||||
|
||||
function leave() {
|
||||
if (connection) {
|
||||
connection.invoke('LeaveRoom', roomId).catch(() => {});
|
||||
connection.stop();
|
||||
}
|
||||
if (pc) pc.close();
|
||||
router.push('/live');
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadRoom();
|
||||
await initSignalR();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (connection) connection.stop();
|
||||
if (pc) pc.close();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="watch-page">
|
||||
<header class="watch-top">
|
||||
<button class="back" @click="leave">‹</button>
|
||||
<div class="title">{{ room.title }}</div>
|
||||
<span class="viewer-count">👀 {{ viewerCount }}</span>
|
||||
</header>
|
||||
|
||||
<div class="video-area">
|
||||
<video ref="remoteVideo" autoplay playsinline class="remote-video"></video>
|
||||
<div v-if="connecting" class="connecting">
|
||||
<div class="spinner"></div>
|
||||
<p>连接直播中...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 弹幕飘屏 -->
|
||||
<div class="danmaku-overlay">
|
||||
<div v-for="(d, i) in danmakuList" :key="i" class="danmaku-item">
|
||||
<span class="dm-name">{{ d.nickname }}:</span>{{ d.content }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="watch-footer">
|
||||
<input v-model="danmakuInput" placeholder="发弹幕..." @keyup.enter="sendDanmaku" />
|
||||
<button class="btn btn-sm" @click="sendDanmaku">发送</button>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.watch-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.watch-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.back {
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 28px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.viewer-count {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.video-area {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.remote-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.connecting {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.danmaku-overlay {
|
||||
position: absolute;
|
||||
top: 80px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
pointer-events: none;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.danmaku-item {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: #fff;
|
||||
padding: 6px 12px;
|
||||
border-radius: 16px;
|
||||
font-size: 13px;
|
||||
align-self: flex-start;
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
.dm-name {
|
||||
color: #ffd666;
|
||||
}
|
||||
|
||||
.watch-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.watch-footer input {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,175 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import api from '../api';
|
||||
import { toast } from '../utils';
|
||||
|
||||
const router = useRouter();
|
||||
const mode = ref('login');
|
||||
|
||||
const loginForm = ref({ username: '', password: '' });
|
||||
const regForm = ref({ username: '', password: '', confirm: '', nickname: '', phone: '', building: '' });
|
||||
const loading = ref(false);
|
||||
|
||||
async function handleLogin() {
|
||||
if (!loginForm.value.username || !loginForm.value.password) {
|
||||
return toast('请输入用户名和密码');
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await api.post('/auth/login', loginForm.value);
|
||||
localStorage.setItem('token', data.token);
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
toast('登录成功');
|
||||
router.push('/posts');
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegister() {
|
||||
const { username, password, confirm, nickname } = regForm.value;
|
||||
if (!username || !password || !nickname) {
|
||||
return toast('请填写完整信息');
|
||||
}
|
||||
if (password !== confirm) {
|
||||
return toast('两次密码不一致');
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await api.post('/auth/register', {
|
||||
username,
|
||||
password,
|
||||
nickname,
|
||||
phone: regForm.value.phone,
|
||||
building: regForm.value.building
|
||||
});
|
||||
localStorage.setItem('token', data.token);
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
toast('注册成功');
|
||||
router.push('/posts');
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<div class="auth-card">
|
||||
<div class="auth-header">
|
||||
<div class="auth-logo">🏘️</div>
|
||||
<h1>社区生活圈</h1>
|
||||
<p>邻里互助 · 便捷生活</p>
|
||||
</div>
|
||||
|
||||
<div class="mode-tabs">
|
||||
<button :class="{ active: mode === 'login' }" @click="mode = 'login'">登录</button>
|
||||
<button :class="{ active: mode === 'register' }" @click="mode = 'register'">注册</button>
|
||||
</div>
|
||||
|
||||
<!-- 登录 -->
|
||||
<form v-if="mode === 'login'" @submit.prevent="handleLogin" class="form">
|
||||
<input v-model="loginForm.username" placeholder="用户名" />
|
||||
<input v-model="loginForm.password" type="password" placeholder="密码" />
|
||||
<button class="btn" :disabled="loading" type="submit">
|
||||
{{ loading ? '登录中...' : '登 录' }}
|
||||
</button>
|
||||
<div class="hint">管理员账号:admin / admin123</div>
|
||||
</form>
|
||||
|
||||
<!-- 注册 -->
|
||||
<form v-else @submit.prevent="handleRegister" class="form">
|
||||
<input v-model="regForm.username" placeholder="用户名(至少3位)" />
|
||||
<input v-model="regForm.nickname" placeholder="昵称" />
|
||||
<input v-model="regForm.password" type="password" placeholder="密码(至少6位)" />
|
||||
<input v-model="regForm.confirm" type="password" placeholder="确认密码" />
|
||||
<input v-model="regForm.phone" placeholder="手机号(选填)" />
|
||||
<input v-model="regForm.building" placeholder="楼栋/房号(选填)" />
|
||||
<button class="btn" :disabled="loading" type="submit">
|
||||
{{ loading ? '注册中...' : '注 册' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #2f7d5c 0%, #3f9d76 100%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.auth-logo {
|
||||
font-size: 48px;
|
||||
}
|
||||
|
||||
.auth-header h1 {
|
||||
font-size: 24px;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
|
||||
.auth-header p {
|
||||
color: var(--text-light);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.mode-tabs {
|
||||
display: flex;
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.mode-tabs button {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
color: var(--text-light);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.mode-tabs button.active {
|
||||
background: #fff;
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
text-align: center;
|
||||
color: var(--text-light);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import api from '../api';
|
||||
import { toast, formatTime } from '../utils';
|
||||
|
||||
const user = ref(JSON.parse(localStorage.getItem('user') || '{}'));
|
||||
const notices = ref([]);
|
||||
const showForm = ref(false);
|
||||
const form = ref({ title: '', content: '', type: '通知' });
|
||||
const expanded = ref(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
notices.value = await api.get('/notices');
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
if (!form.value.title || !form.value.content) return toast('请填写标题和内容');
|
||||
try {
|
||||
await api.post('/notices', form.value);
|
||||
form.value = { title: '', content: '', type: '通知' };
|
||||
showForm.value = false;
|
||||
toast('发布成功');
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-title">物业通知</div>
|
||||
|
||||
<button v-if="user.role === 'admin'" class="btn" @click="showForm = !showForm">
|
||||
{{ showForm ? '收起' : '发布通知' }}
|
||||
</button>
|
||||
|
||||
<div v-if="showForm" class="card form-card">
|
||||
<input v-model="form.title" placeholder="通知标题" />
|
||||
<select v-model="form.type">
|
||||
<option value="通知">通知</option>
|
||||
<option value="公告">公告</option>
|
||||
<option value="提醒">提醒</option>
|
||||
</select>
|
||||
<textarea v-model="form.content" rows="4" placeholder="通知内容"></textarea>
|
||||
<button class="btn" @click="publish">发布</button>
|
||||
</div>
|
||||
|
||||
<div v-if="notices.length === 0" class="empty">暂无通知</div>
|
||||
<div
|
||||
v-for="n in notices"
|
||||
:key="n.id"
|
||||
class="card notice"
|
||||
@click="expanded = expanded === n.id ? null : n.id"
|
||||
>
|
||||
<div class="notice-head">
|
||||
<div class="notice-title">
|
||||
<span class="dot"></span>
|
||||
{{ n.title }}
|
||||
</div>
|
||||
<span class="badge">{{ n.type }}</span>
|
||||
</div>
|
||||
<div class="time">{{ formatTime(n.created_at) }}</div>
|
||||
<div v-if="expanded === n.id" class="notice-content">{{ n.content }}</div>
|
||||
<div v-else class="notice-preview">{{ n.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin-bottom: 12px;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.notice:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.notice-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.notice-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--danger);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notice-preview {
|
||||
color: var(--text-light);
|
||||
font-size: 14px;
|
||||
margin-top: 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notice-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
margin-top: 8px;
|
||||
white-space: pre-wrap;
|
||||
color: var(--text);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,420 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import api from '../api';
|
||||
import { toast, formatTime } from '../utils';
|
||||
|
||||
const user = ref(JSON.parse(localStorage.getItem('user') || '{}'));
|
||||
const posts = ref([]);
|
||||
const category = ref('全部');
|
||||
const categories = ['全部', '动态', '求助', '分享', '吐槽'];
|
||||
|
||||
const newPost = ref({ content: '', images: '', category: '动态' });
|
||||
const publishing = ref(false);
|
||||
|
||||
// 评论相关
|
||||
const commentPost = ref(null);
|
||||
const comments = ref([]);
|
||||
const commentText = ref('');
|
||||
const commentLoading = ref(false);
|
||||
|
||||
async function loadPosts() {
|
||||
try {
|
||||
posts.value = await api.get('/posts', { params: { category: category.value } });
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
if (!newPost.value.content.trim()) return toast('内容不能为空');
|
||||
const images = newPost.value.images
|
||||
.split('\n')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean);
|
||||
publishing.value = true;
|
||||
try {
|
||||
await api.post('/posts', { ...newPost.value, images });
|
||||
newPost.value = { content: '', images: '', category: '动态' };
|
||||
toast('发布成功');
|
||||
await loadPosts();
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
} finally {
|
||||
publishing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleLike(post) {
|
||||
try {
|
||||
const res = await api.post(`/posts/${post.id}/like`);
|
||||
post.liked = res.liked;
|
||||
post.like_count += res.liked ? 1 : -1;
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function openComments(post) {
|
||||
commentPost.value = post;
|
||||
commentText.value = '';
|
||||
commentLoading.value = true;
|
||||
try {
|
||||
comments.value = await api.get(`/posts/${post.id}/comments`);
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
} finally {
|
||||
commentLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitComment() {
|
||||
if (!commentText.value.trim()) return toast('评论不能为空');
|
||||
try {
|
||||
comments.value = await api.post(`/posts/${commentPost.value.id}/comments`, {
|
||||
content: commentText.value
|
||||
});
|
||||
commentText.value = '';
|
||||
commentPost.value.comment_count = comments.value.length;
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function removePost(post) {
|
||||
if (!confirm('确定删除这条动态吗?')) return;
|
||||
try {
|
||||
await api.delete(`/posts/${post.id}`);
|
||||
toast('删除成功');
|
||||
await loadPosts();
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function changeCategory() {
|
||||
loadPosts();
|
||||
}
|
||||
|
||||
onMounted(loadPosts);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-title">社区动态</div>
|
||||
|
||||
<!-- 发布框 -->
|
||||
<div class="card composer">
|
||||
<div class="composer-top">
|
||||
<div class="avatar">{{ user.nickname?.charAt(0) }}</div>
|
||||
<textarea
|
||||
v-model="newPost.content"
|
||||
placeholder="分享你的生活点滴,或向邻居求助..."
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="composer-bottom">
|
||||
<select v-model="newPost.category">
|
||||
<option v-for="c in categories.slice(1)" :key="c" :value="c">{{ c }}</option>
|
||||
</select>
|
||||
<button class="btn" :disabled="publishing" @click="publish">
|
||||
{{ publishing ? '发布中...' : '发布' }}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
v-model="newPost.images"
|
||||
class="img-input"
|
||||
placeholder="图片链接(可选,多个链接每行一个)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 分类筛选 -->
|
||||
<div class="cats">
|
||||
<button
|
||||
v-for="c in categories"
|
||||
:key="c"
|
||||
class="cat"
|
||||
:class="{ active: category === c }"
|
||||
@click="category = c; changeCategory()"
|
||||
>
|
||||
{{ c }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 动态列表 -->
|
||||
<div v-if="posts.length === 0" class="empty">暂无动态,快来发布第一条吧~</div>
|
||||
<div v-for="post in posts" :key="post.id" class="card post">
|
||||
<div class="post-head">
|
||||
<div class="avatar">{{ post.nickname?.charAt(0) }}</div>
|
||||
<div class="post-author">
|
||||
<div class="name">
|
||||
{{ post.nickname }}
|
||||
<span class="tag">{{ post.category }}</span>
|
||||
</div>
|
||||
<div class="time">{{ formatTime(post.created_at) }}</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="post.user_id === user.id || user.role === 'admin'"
|
||||
class="del"
|
||||
@click="removePost(post)"
|
||||
>✕</button>
|
||||
</div>
|
||||
|
||||
<div class="post-content">{{ post.content }}</div>
|
||||
|
||||
<div v-if="post.images && post.images.length" class="post-images">
|
||||
<img
|
||||
v-for="(img, i) in post.images"
|
||||
:key="i"
|
||||
:src="img"
|
||||
:alt="'图片' + (i + 1)"
|
||||
@error="$event.target.style.display = 'none'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="post-actions">
|
||||
<button class="action" :class="{ liked: post.liked }" @click="toggleLike(post)">
|
||||
{{ post.liked ? '❤️' : '🤍' }} {{ post.like_count }}
|
||||
</button>
|
||||
<button class="action" @click="openComments(post)">
|
||||
💬 {{ post.comment_count }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 评论弹窗 -->
|
||||
<div v-if="commentPost" class="mask" @click.self="commentPost = null">
|
||||
<div class="comment-panel">
|
||||
<div class="comment-head">
|
||||
<h3>评论</h3>
|
||||
<button class="close" @click="commentPost = null">✕</button>
|
||||
</div>
|
||||
<div class="comment-list">
|
||||
<div v-if="commentLoading" class="empty">加载中...</div>
|
||||
<div v-else-if="comments.length === 0" class="empty">还没有评论,快来抢沙发~</div>
|
||||
<div v-for="c in comments" :key="c.id" class="comment-item">
|
||||
<div class="avatar sm">{{ c.nickname?.charAt(0) }}</div>
|
||||
<div class="comment-body">
|
||||
<div class="comment-name">{{ c.nickname }}</div>
|
||||
<div class="comment-text">{{ c.content }}</div>
|
||||
<div class="time">{{ formatTime(c.created_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comment-input">
|
||||
<input v-model="commentText" placeholder="写下你的评论..." @keyup.enter="submitComment" />
|
||||
<button class="btn btn-sm" @click="submitComment">发送</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.composer {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.composer-top {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.composer-top textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
border: none;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.composer-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.composer-bottom select {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.img-input {
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.cats {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.cat {
|
||||
flex-shrink: 0;
|
||||
padding: 6px 16px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
color: var(--text-light);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.cat.active {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.post {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.post-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.post-author {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.del {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-light);
|
||||
font-size: 14px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.post-content {
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.post-images {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.post-images img {
|
||||
width: calc(33.33% - 6px);
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.post-actions {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.action {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-light);
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.action.liked {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.comment-panel {
|
||||
background: #fff;
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
border-radius: 16px 16px 0 0;
|
||||
padding: 20px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slideUp 0.3s;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
.comment-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.close {
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 18px;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.comment-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.comment-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.avatar.sm {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.comment-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.comment-text {
|
||||
font-size: 14px;
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.comment-input {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.comment-input input {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,196 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import api from '../api';
|
||||
import { toast } from '../utils';
|
||||
|
||||
const router = useRouter();
|
||||
const user = ref(JSON.parse(localStorage.getItem('user') || '{}'));
|
||||
const editMode = ref(false);
|
||||
const form = ref({ nickname: '', phone: '', building: '', bio: '' });
|
||||
const pwdForm = ref({ oldPassword: '', newPassword: '', confirm: '' });
|
||||
const showPwd = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await api.get('/auth/me');
|
||||
user.value = data;
|
||||
localStorage.setItem('user', JSON.stringify(data));
|
||||
form.value = { nickname: data.nickname, phone: data.phone, building: data.building, bio: data.bio };
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
});
|
||||
|
||||
async function saveProfile() {
|
||||
try {
|
||||
const data = await api.put('/auth/me', form.value);
|
||||
user.value = data;
|
||||
localStorage.setItem('user', JSON.stringify(data));
|
||||
editMode.value = false;
|
||||
toast('保存成功');
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function changePassword() {
|
||||
const { oldPassword, newPassword, confirm } = pwdForm.value;
|
||||
if (!oldPassword || !newPassword) return toast('请填写完整');
|
||||
if (newPassword !== confirm) return toast('两次新密码不一致');
|
||||
try {
|
||||
await api.put('/auth/password', { oldPassword, newPassword });
|
||||
toast('密码修改成功');
|
||||
pwdForm.value = { oldPassword: '', newPassword: '', confirm: '' };
|
||||
showPwd.value = false;
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
router.push('/login');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="card profile-card">
|
||||
<div class="avatar big">{{ user.nickname?.charAt(0) }}</div>
|
||||
<div class="info">
|
||||
<div class="name">{{ user.nickname }}</div>
|
||||
<div class="meta">@{{ user.username }}</div>
|
||||
<div class="meta" v-if="user.building">📍 {{ user.building }}</div>
|
||||
<div class="meta" v-if="user.bio">{{ user.bio }}</div>
|
||||
</div>
|
||||
<span v-if="user.role === 'admin'" class="badge">管理员</span>
|
||||
</div>
|
||||
|
||||
<div class="card section">
|
||||
<div class="section-title">
|
||||
<h3>个人资料</h3>
|
||||
<button class="btn btn-ghost btn-sm" @click="editMode = !editMode">
|
||||
{{ editMode ? '取消' : '编辑' }}
|
||||
</button>
|
||||
</div>
|
||||
<template v-if="editMode">
|
||||
<div class="form">
|
||||
<label>昵称</label>
|
||||
<input v-model="form.nickname" />
|
||||
<label>手机号</label>
|
||||
<input v-model="form.phone" />
|
||||
<label>楼栋/房号</label>
|
||||
<input v-model="form.building" />
|
||||
<label>个人简介</label>
|
||||
<textarea v-model="form.bio" rows="3"></textarea>
|
||||
<button class="btn" @click="saveProfile">保存</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="kv">
|
||||
<div class="row"><span>昵称</span><span>{{ user.nickname }}</span></div>
|
||||
<div class="row"><span>手机号</span><span>{{ user.phone || '未填写' }}</span></div>
|
||||
<div class="row"><span>楼栋/房号</span><span>{{ user.building || '未填写' }}</span></div>
|
||||
<div class="row"><span>个人简介</span><span>{{ user.bio || '未填写' }}</span></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="card section">
|
||||
<div class="section-title">
|
||||
<h3>账号安全</h3>
|
||||
<button class="btn btn-ghost btn-sm" @click="showPwd = !showPwd">
|
||||
{{ showPwd ? '收起' : '修改密码' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="showPwd" class="form">
|
||||
<input v-model="pwdForm.oldPassword" type="password" placeholder="原密码" />
|
||||
<input v-model="pwdForm.newPassword" type="password" placeholder="新密码(至少6位)" />
|
||||
<input v-model="pwdForm.confirm" type="password" placeholder="确认新密码" />
|
||||
<button class="btn" @click="changePassword">确认修改</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-danger logout-btn" @click="logout">退出登录</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profile-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.avatar.big {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--text-light);
|
||||
font-size: 13px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title h3 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.form label {
|
||||
font-size: 13px;
|
||||
color: var(--text-light);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.kv .row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.kv .row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.kv .row span:first-child {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,190 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import api from '../api';
|
||||
import { toast, formatTime } from '../utils';
|
||||
|
||||
const user = ref(JSON.parse(localStorage.getItem('user') || '{}'));
|
||||
const items = ref([]);
|
||||
const showForm = ref(false);
|
||||
const form = ref({ title: '', description: '', price: '', images: '' });
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
items.value = await api.get('/secondhand');
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
if (!form.value.title || !form.value.description) return toast('请填写标题和描述');
|
||||
const images = form.value.images
|
||||
.split('\n')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean);
|
||||
try {
|
||||
await api.post('/secondhand', {
|
||||
...form.value,
|
||||
price: parseFloat(form.value.price) || 0,
|
||||
images
|
||||
});
|
||||
form.value = { title: '', description: '', price: '', images: '' };
|
||||
showForm.value = false;
|
||||
toast('发布成功');
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function changeStatus(item, status) {
|
||||
try {
|
||||
await api.put(`/secondhand/${item.id}`, { status });
|
||||
toast(status === '已售出' ? '已标记售出' : '已下架');
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(item) {
|
||||
if (!confirm('确定删除吗?')) return;
|
||||
try {
|
||||
await api.delete(`/secondhand/${item.id}`);
|
||||
toast('删除成功');
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-title">二手闲置</div>
|
||||
|
||||
<button class="btn" @click="showForm = !showForm">
|
||||
{{ showForm ? '收起' : '发布闲置' }}
|
||||
</button>
|
||||
|
||||
<div v-if="showForm" class="card form-card">
|
||||
<input v-model="form.title" placeholder="物品名称" />
|
||||
<input v-model="form.price" type="number" placeholder="价格(元)" />
|
||||
<textarea v-model="form.description" rows="3" placeholder="物品描述"></textarea>
|
||||
<input v-model="form.images" placeholder="图片链接(可选,每行一个)" />
|
||||
<button class="btn" @click="publish">发布</button>
|
||||
</div>
|
||||
|
||||
<div v-if="items.length === 0" class="empty">暂无闲置物品</div>
|
||||
<div class="grid">
|
||||
<div v-for="item in items" :key="item.id" class="card item">
|
||||
<div v-if="item.images && item.images.length" class="item-img">
|
||||
<img :src="item.images[0]" @error="$event.target.style.display = 'none'" />
|
||||
</div>
|
||||
<div v-else class="item-img placeholder">🛒</div>
|
||||
<div class="item-body">
|
||||
<div class="item-title">{{ item.title }}</div>
|
||||
<div class="item-price">¥{{ item.price }}</div>
|
||||
<div class="item-desc">{{ item.description }}</div>
|
||||
<div class="item-foot">
|
||||
<span class="time">{{ item.nickname }} · {{ formatTime(item.created_at) }}</span>
|
||||
<span class="tag" :class="item.status">{{ item.status }}</span>
|
||||
</div>
|
||||
<div v-if="item.user_id === user.id || user.role === 'admin'" class="item-ops">
|
||||
<template v-if="item.status === '在售'">
|
||||
<button class="btn btn-sm" @click="changeStatus(item, '已售出')">标记售出</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="changeStatus(item, '已下架')">下架</button>
|
||||
</template>
|
||||
<button class="btn btn-ghost btn-sm" @click="remove(item)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.grid {
|
||||
margin-top: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.item-img {
|
||||
width: 100%;
|
||||
aspect-ratio: 4/3;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.item-img img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.item-img.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.item-price {
|
||||
color: #e74c3c;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.item-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-light);
|
||||
margin-bottom: 8px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.item-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.tag.已售出 {
|
||||
background: #fdecea;
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.tag.已下架 {
|
||||
background: #eef0f0;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.item-ops {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user