first commit

This commit is contained in:
yanpeng200212
2026-10-08 10:43:59 +08:00
commit 8430ba0452
288 changed files with 29595 additions and 0 deletions
+489
View File
@@ -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>
+233
View File
@@ -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>
+176
View File
@@ -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>
+340
View File
@@ -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>
+177
View File
@@ -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>
+259
View File
@@ -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>
+175
View File
@@ -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>
+133
View File
@@ -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>
+420
View File
@@ -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>
+196
View File
@@ -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>
+190
View File
@@ -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>