用户消息系统:消息列表、订单群聊、联系对方入口

- 新增消息列表页和聊天详情页,支持 SSE 实时推送
- 订单详情页新增"联系对方"按钮,一键进入订单群聊
- 导航栏新增"消息"入口
- 修复 mobileHost 路径匹配 bug,避免 /m 前缀误匹配
- 优化 dev.sh:提取 mysql 辅助函数、修复空 PID 处理、条件检查依赖

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
yml
2026-05-27 09:46:34 +08:00
co-authored by Claude Opus 4.7
parent 8cbd06dca4
commit 6fb5de204c
7 changed files with 792 additions and 14 deletions
+2
View File
@@ -3,6 +3,7 @@ import { ref } from "vue";
import { useRouter } from "vue-router";
import {
Bell,
ChatDotRound,
CirclePlus,
House,
Search,
@@ -21,6 +22,7 @@ const navItems = [
{ label: "首页", to: "/", icon: House },
{ label: "租号大厅", to: "/listings", icon: Shop },
{ label: "我的订单", to: "/orders", icon: Tickets },
{ label: "消息", to: "/messages", icon: ChatDotRound },
{ label: "钱包", to: "/wallet", icon: Wallet },
];
+12
View File
@@ -42,4 +42,16 @@ export const accountRoutes: RouteRecordRaw[] = [
component: () => import('@/views/account/RealnameView.vue'),
meta: { requiresAuth: true },
},
{
path: '/messages',
name: 'messages',
component: () => import('@/views/account/MessagesView.vue'),
meta: { requiresAuth: true },
},
{
path: '/messages/:id',
name: 'chat',
component: () => import('@/views/account/ChatView.vue'),
meta: { requiresAuth: true },
},
]
+7 -3
View File
@@ -14,7 +14,7 @@ export function getMobilePath(path: string): string {
if (path === "/" || path === "") {
return "/m";
}
if (path.startsWith("/m") || path.startsWith("/admin")) {
if (path === "/m" || path.startsWith("/m/") || path.startsWith("/admin")) {
return path;
}
@@ -31,6 +31,9 @@ export function getMobilePath(path: string): string {
if (path === "/seller/listings/create") {
return "/m/seller/listings/create";
}
if (path === "/messages" || path.startsWith("/messages/")) {
return "/m/messages";
}
// PC-only views that don't have mobile counterparts go to mobile profile as fallback
if (path === "/wallet" || path === "/notifications" || path.startsWith("/seller")) {
@@ -41,7 +44,7 @@ export function getMobilePath(path: string): string {
}
export function getPcPath(path: string): string {
if (!path.startsWith("/m") || path.startsWith("/admin")) {
if ((path !== "/m" && !path.startsWith("/m/")) || path.startsWith("/admin")) {
return path;
}
@@ -62,7 +65,8 @@ export function getPcPath(path: string): string {
if (subPath === "/realname") return "/realname";
if (subPath === "/seller/listings/create") return "/seller/listings/create";
if (subPath === "/messages") return "/";
if (subPath === "/messages") return "/messages";
if (subPath.startsWith("/chats/")) return "/messages/" + subPath.substring(7);
if (subPath === "/profile") return "/wallet";
return subPath || "/";
+413
View File
@@ -0,0 +1,413 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
import {
fetchChat,
fetchChatMessages,
markChatRead,
sendChatMessage,
type ChatConversation,
type ChatMessage,
} from '@/api/chats'
import { useChatSSE, type ChatEvent } from '@/composables/useChatSSE'
import { formatDateMinute } from '@/utils/time'
const currentUserId = Number(localStorage.getItem('user_id') || 0)
const route = useRoute()
const router = useRouter()
const conversation = ref<ChatConversation | null>(null)
const messages = ref<ChatMessage[]>([])
const loading = ref(false)
const sending = ref(false)
const content = ref('')
const listRef = ref<HTMLElement | null>(null)
const conversationID = computed(() => Number(route.params.id || 0))
const memberText = computed(() => {
const participants = conversation.value?.participants || []
if (participants.length === 0) return '订单群聊'
return participants.map(item => roleLabel(item.role)).join(' · ')
})
function handleSSEEvent(event: ChatEvent) {
if (event.type === 'new_message' && event.conversation_id === conversationID.value) {
const msg = event.message
if (msg) appendMessage({
id: msg.id,
conversation_id: msg.conversation_id,
sender_type: msg.sender_type as ChatMessage['sender_type'],
sender_id: msg.sender_id,
sender_role: msg.sender_role as ChatMessage['sender_role'],
sender_name: msg.sender_name,
sender_avatar: '',
is_self: msg.sender_type === 'user' && msg.sender_id === currentUserId,
content_type: msg.content_type as ChatMessage['content_type'],
content: msg.content,
attachment_urls: msg.attachment_urls || [],
created_at: msg.created_at,
})
markChatRead(conversationID.value).catch(() => {})
}
if (event.type === 'conversation_updated' && event.conversation_id === conversationID.value) {
loadConversation()
}
}
const { onEvent } = useChatSSE('user', '/api/chats/events')
onEvent(handleSSEEvent)
onMounted(async () => {
await loadAll()
})
watch(conversationID, async (id, oldId) => {
if (id && id !== oldId) {
await loadAll()
}
})
async function loadAll() {
if (!conversationID.value) return
loading.value = true
conversation.value = null
messages.value = []
try {
const [chat] = await Promise.all([
fetchChat(conversationID.value),
loadMessages(true),
])
conversation.value = chat
await markChatRead(conversationID.value)
} catch {
ElMessage.error('加载会话失败')
} finally {
loading.value = false
}
}
async function loadConversation() {
if (!conversationID.value) return
try {
conversation.value = await fetchChat(conversationID.value)
} catch { /* ignore */ }
}
async function loadMessages(scrollToBottom = true) {
if (!conversationID.value) return
const res = await fetchChatMessages(conversationID.value, 1, 100)
messages.value = res.items
if (scrollToBottom) {
await nextTick()
scrollBottom()
}
}
async function handleSend() {
const text = content.value.trim()
if (!text || sending.value) return
sending.value = true
try {
const sent = await sendChatMessage(conversationID.value, text)
appendMessage(sent)
content.value = ''
await loadConversation()
} catch {
ElMessage.error('发送失败')
} finally {
sending.value = false
}
}
function appendMessage(message: ChatMessage) {
if (messages.value.some(item => item.id === message.id)) return
messages.value = [...messages.value, message]
nextTick(() => scrollBottom())
}
function scrollBottom() {
const el = listRef.value
if (!el) return
el.scrollTop = el.scrollHeight
}
function roleLabel(role: string) {
const map: Record<string, string> = {
renter: '租客',
owner: '号主',
support: '客服',
system: '系统',
}
return map[role] || '成员'
}
function senderLabel(message: ChatMessage) {
if (message.sender_type === 'system') return '系统'
return `${roleLabel(message.sender_role)} · ${message.sender_name}`
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
</script>
<template>
<section class="page chat-page">
<div class="chat-workbench">
<!-- Header -->
<div class="chat-header">
<button class="back-btn" type="button" @click="router.push('/messages')">
<el-icon :size="18"><ArrowLeft /></el-icon>
<span>返回</span>
</button>
<div class="chat-title">
<h2>{{ conversation?.title || '订单群聊' }}</h2>
<p>{{ memberText }}</p>
</div>
<el-button
v-if="conversation?.order_id"
type="primary"
link
@click="router.push(`/orders/${conversation.order_id}`)"
>
查看订单
</el-button>
</div>
<!-- Messages -->
<div ref="listRef" v-loading="loading" class="message-list">
<div v-if="loading && messages.length === 0" class="loading-placeholder">
<el-icon class="is-loading" :size="24"><Loading /></el-icon>
</div>
<el-empty v-else-if="!loading && messages.length === 0" description="暂无消息" />
<div
v-for="item in messages"
:key="item.id"
class="message-row"
:class="{ self: item.is_self, system: item.sender_type === 'system' }"
>
<template v-if="item.sender_type === 'system'">
<span class="system-message">{{ item.content }}</span>
</template>
<template v-else>
<div class="avatar" :class="{ self: item.is_self }">{{ roleLabel(item.sender_role).slice(0, 1) }}</div>
<div class="bubble-wrap" :class="{ self: item.is_self }">
<span class="sender-name">{{ senderLabel(item) }}</span>
<div class="bubble">{{ item.content }}</div>
<span class="message-time">{{ formatDateMinute(item.created_at) }}</span>
</div>
</template>
</div>
</div>
<!-- Composer -->
<div class="composer">
<el-input
v-model="content"
type="textarea"
:rows="3"
:maxlength="1000"
show-word-limit
placeholder="发送消息..."
resize="none"
@keydown="handleKeydown"
/>
<div class="composer-actions">
<span class="composer-hint">Enter 发送Shift+Enter 换行</span>
<el-button type="primary" :disabled="!content.trim() || sending" :loading="sending" @click="handleSend">
发送
</el-button>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.chat-page {
max-width: 900px;
margin: 0 auto;
}
.chat-workbench {
display: flex;
flex-direction: column;
height: calc(100vh - 56px - 40px);
border: 1px solid #e8edf3;
border-radius: 12px;
background: #fff;
overflow: hidden;
}
.chat-header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid #eef1f5;
background: #fafbfc;
flex-shrink: 0;
}
.back-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
border: none;
border-radius: 8px;
background: transparent;
color: #5a6577;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.back-btn:hover {
background: #f0f5ff;
color: #1477ff;
}
.chat-title {
flex: 1;
min-width: 0;
}
.chat-title h2 {
margin: 0;
overflow: hidden;
color: #17233d;
font-size: 15px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-title p {
margin: 2px 0 0;
color: #6b7785;
font-size: 12px;
}
.message-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 16px;
}
.loading-placeholder {
display: flex;
justify-content: center;
padding: 60px 0;
color: #a0aab6;
}
.message-row {
display: flex;
gap: 10px;
margin-bottom: 16px;
}
.message-row.self {
flex-direction: row-reverse;
}
.message-row.system {
justify-content: center;
}
.avatar {
display: grid;
flex: none;
width: 36px;
height: 36px;
place-items: center;
border-radius: 50%;
background: #1477ff;
color: #fff;
font-size: 14px;
font-weight: 800;
}
.avatar.self {
background: #10b981;
}
.bubble-wrap {
display: flex;
max-width: 60%;
flex-direction: column;
align-items: flex-start;
}
.bubble-wrap.self {
align-items: flex-end;
}
.sender-name {
margin-bottom: 4px;
color: #8a94a6;
font-size: 12px;
}
.bubble {
max-width: 100%;
padding: 10px 14px;
border-radius: 10px;
background: #f4f6f8;
color: #17233d;
font-size: 14px;
line-height: 1.5;
word-break: break-word;
}
.message-row.self .bubble {
background: #dff5eb;
color: #0f5132;
}
.message-time {
margin-top: 4px;
color: #a1a8b4;
font-size: 11px;
}
.system-message {
max-width: 80%;
padding: 6px 12px;
border-radius: 8px;
background: #e6ebf2;
color: #6b7280;
font-size: 12px;
line-height: 1.4;
text-align: center;
}
.composer {
flex-shrink: 0;
padding: 12px 16px;
border-top: 1px solid #eef1f5;
background: #fafbfc;
}
.composer-actions {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
}
.composer-hint {
color: #a0aab6;
font-size: 12px;
}
</style>
+290
View File
@@ -0,0 +1,290 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ChatDotRound, Refresh, Tickets } from '@element-plus/icons-vue'
import { fetchChats, type ChatConversation } from '@/api/chats'
import { useChatSSE, type ChatEvent } from '@/composables/useChatSSE'
import { formatDateMinute } from '@/utils/time'
const router = useRouter()
const loading = ref(false)
const conversations = ref<ChatConversation[]>([])
const page = ref(1)
const pageSize = 20
const total = ref(0)
const hasMore = computed(() => conversations.value.length < total.value)
const unreadTotal = computed(() => conversations.value.reduce((sum, item) => sum + item.unread_count, 0))
const { onEvent } = useChatSSE('user', '/api/chats/events')
onEvent(handleSSEEvent)
onMounted(() => loadChats(true))
async function loadChats(isRefresh = false, showLoading = true) {
if (isRefresh) page.value = 1
if (loading.value) return
if (showLoading) loading.value = true
try {
const res = await fetchChats(page.value, pageSize)
conversations.value = isRefresh ? res.items : [...conversations.value, ...res.items]
total.value = res.total
if (conversations.value.length < res.total && res.items.length > 0) {
page.value += 1
}
} catch {
ElMessage.error('获取会话失败')
} finally {
loading.value = false
}
}
function handleSSEEvent(event: ChatEvent) {
if (event.type === 'new_message' || event.type === 'conversation_updated') {
loadChats(true, false)
}
}
function openConversation(item: ChatConversation) {
router.push(`/messages/${item.id}`)
}
function roleLabel(role: string) {
const map: Record<string, string> = { renter: '租客', owner: '号主', support: '客服' }
return map[role] || '成员'
}
function previewText(item: ChatConversation) {
return item.last_message_preview || '订单群聊已创建'
}
</script>
<template>
<section class="page messages-page">
<div class="page-header-row">
<div class="page-header">
<p class="eyebrow">Messages</p>
<h1>消息</h1>
<p>{{ unreadTotal > 0 ? `${unreadTotal} 条未读` : '查看订单群聊消息,与租客、号主和客服沟通。' }}</p>
</div>
<div class="header-actions">
<el-button :icon="Refresh" :loading="loading" @click="loadChats(true)">刷新</el-button>
<el-button type="primary" :icon="Tickets" @click="router.push('/orders')">我的订单</el-button>
</div>
</div>
<div v-if="loading && conversations.length === 0" class="message-loading" v-loading="loading" />
<div v-else-if="!loading && conversations.length === 0" class="empty-panel">
<el-empty description="暂无订单群聊">
<el-button type="primary" :icon="Tickets" @click="router.push('/orders')">查看订单</el-button>
</el-empty>
</div>
<div v-else v-loading="loading" class="conversation-list">
<button
v-for="item in conversations"
:key="item.id"
class="conversation-item"
type="button"
@click="openConversation(item)"
>
<div class="avatar-stack">
<span class="avatar main">{{ roleLabel(item.role).slice(0, 1) }}</span>
<span class="avatar support"></span>
</div>
<div class="conversation-body">
<div class="conversation-head">
<h2>{{ item.title }}</h2>
<span class="conversation-time">{{ formatDateMinute(item.last_message_at || item.created_at) }}</span>
</div>
<div class="conversation-meta">
<span class="role-chip">{{ roleLabel(item.role) }}</span>
<span class="order-id">订单 #{{ item.order_id }}</span>
</div>
<p class="conversation-preview">{{ previewText(item) }}</p>
</div>
<span v-if="item.unread_count > 0" class="unread-badge">
{{ item.unread_count > 99 ? '99+' : item.unread_count }}
</span>
</button>
</div>
<div v-if="hasMore && conversations.length > 0" class="pagination-wrap">
<el-button :loading="loading" @click="loadChats(false)">加载更多</el-button>
</div>
</section>
</template>
<style scoped>
.messages-page {
display: flex;
flex-direction: column;
gap: 18px;
}
.page-header-row {
align-items: flex-end;
}
.header-actions {
display: flex;
gap: 10px;
}
.message-loading,
.empty-panel {
min-height: 360px;
border: 1px solid #e8edf3;
border-radius: 8px;
background: #fff;
}
.empty-panel {
display: grid;
place-items: center;
}
.conversation-list {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 180px;
}
.conversation-item {
position: relative;
display: grid;
grid-template-columns: 52px minmax(0, 1fr);
gap: 12px;
width: 100%;
padding: 14px 16px;
border: 1px solid #e8edf3;
border-radius: 10px;
background: #fff;
text-align: left;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
}
.conversation-item:hover {
border-color: #1477ff;
box-shadow: 0 4px 16px rgba(20, 119, 255, 0.1);
}
.avatar-stack {
position: relative;
width: 48px;
height: 48px;
flex-shrink: 0;
}
.avatar {
display: grid;
place-items: center;
border-radius: 50%;
color: #fff;
font-weight: 800;
}
.avatar.main {
width: 44px;
height: 44px;
background: #1477ff;
font-size: 16px;
}
.avatar.support {
position: absolute;
right: 0;
bottom: 0;
width: 22px;
height: 22px;
border: 2px solid #fff;
background: #10b981;
font-size: 11px;
}
.conversation-body {
min-width: 0;
}
.conversation-head {
display: flex;
align-items: center;
gap: 8px;
}
.conversation-head h2 {
flex: 1;
min-width: 0;
margin: 0;
overflow: hidden;
color: #17233d;
font-size: 15px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-time {
flex: none;
color: #9ca3af;
font-size: 12px;
}
.conversation-meta {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
color: #6b7785;
font-size: 12px;
}
.role-chip {
padding: 1px 8px;
border-radius: 999px;
background: #eef6ff;
color: #1477ff;
font-size: 11px;
font-weight: 700;
}
.order-id {
color: #8a94a6;
}
.conversation-preview {
margin: 6px 0 0;
overflow: hidden;
color: #4b5563;
font-size: 13px;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.unread-badge {
position: absolute;
right: 14px;
bottom: 14px;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 10px;
background: #ef4444;
color: #fff;
font-size: 11px;
font-weight: 800;
line-height: 20px;
text-align: center;
}
.pagination-wrap {
display: flex;
justify-content: center;
}
</style>
+34 -1
View File
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { ChatDotRound } from '@element-plus/icons-vue'
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { fetchOrderChat } from '@/api/chats'
import { createDispute } from '@/api/disputes'
import { uploadFile } from '@/api/files'
import {
@@ -61,6 +63,7 @@ const rejectReason = ref('')
const disputeType = ref('cannot_login')
const disputeDescription = ref('')
const disputeEvidenceText = ref('')
const openingChat = ref(false)
const isOwner = computed(() => order.value?.owner_id === session.userId)
const isRenter = computed(() => order.value?.renter_id === session.userId)
@@ -300,6 +303,19 @@ async function handleEvidenceUpload(event: Event) {
}
}
async function openOrderChat() {
if (!order.value || openingChat.value) return
openingChat.value = true
try {
const chat = await fetchOrderChat(order.value.id)
await router.push(`/messages/${chat.id}`)
} catch {
ElMessage.error('订单群聊暂不可用')
} finally {
openingChat.value = false
}
}
function readError(error: unknown, fallback: string) {
if (typeof error === 'object' && error && 'response' in error) {
const response = (error as { response?: { data?: { message?: string } } }).response
@@ -463,7 +479,13 @@ function linesToList(value: string) {
<div v-if="order" class="page-header">
<p class="eyebrow">{{ order.order_no }}</p>
<h1>订单详情</h1>
<p>{{ order.title }} · {{ order.server_region }} / {{ order.login_platform }}</p>
<div class="header-actions">
<p>{{ order.title }} · {{ order.server_region }} / {{ order.login_platform }}</p>
<el-button type="primary" :loading="openingChat" @click="openOrderChat">
<el-icon style="margin-right: 4px;"><ChatDotRound /></el-icon>
联系对方
</el-button>
</div>
</div>
<div v-if="order" class="detail-grid">
@@ -644,6 +666,17 @@ function linesToList(value: string) {
</template>
<style scoped>
.header-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.header-actions p {
margin: 0;
}
.checkout-resource-panel {
display: grid;
gap: 10px;
+34 -10
View File
@@ -102,6 +102,10 @@ cleanup() {
fi
CLEANED_UP=1
if [[ -z "${FRONTEND_PID}" && -z "${BACKEND_PID}" ]]; then
return
fi
log "正在停止前后端开发进程..."
if [[ -n "${FRONTEND_PID}" ]] && kill -0 "${FRONTEND_PID}" >/dev/null 2>&1; then
kill "${FRONTEND_PID}" >/dev/null 2>&1 || true
@@ -131,16 +135,24 @@ wait_container_healthy() {
exit 1
}
mysql_root() {
docker exec -i -e MYSQL_PWD=rootsecret hfb-mysql mysql -uroot "$@"
}
mysql_hfb() {
docker exec -i -e MYSQL_PWD=secret hfb-mysql mysql -uhfb "$@"
}
init_database() {
if [[ "${RESET_DB}" == "1" ]]; then
log_warn "重置数据库..."
docker exec hfb-mysql mysql -uroot -prootsecret -e \
mysql_root -e \
"DROP DATABASE IF EXISTS hfb_sys; CREATE DATABASE hfb_sys CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
fi
local table_count
table_count="$(
docker exec hfb-mysql mysql -uhfb -psecret -N -s -e \
mysql_hfb -N -s -e \
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'hfb_sys' AND table_name = 'users';" 2>/dev/null
)"
@@ -149,12 +161,12 @@ init_database() {
local migration
for migration in "${ROOT_DIR}"/backend/migrations/*.sql; do
log "执行迁移:$(basename "${migration}")"
docker exec -i hfb-mysql mysql -uhfb -psecret --default-character-set=utf8mb4 hfb_sys < "${migration}"
mysql_hfb --default-character-set=utf8mb4 hfb_sys < "${migration}"
done
log_success "数据库初始化完成"
elif [[ "${SEED_ONLY}" == "1" ]]; then
log "重新加载种子数据..."
docker exec -i hfb-mysql mysql -uhfb -psecret --default-character-set=utf8mb4 hfb_sys < "${ROOT_DIR}/backend/migrations/000002_seed.sql"
mysql_hfb --default-character-set=utf8mb4 hfb_sys < "${ROOT_DIR}/backend/migrations/000002_seed.sql"
log_success "种子数据加载完成"
else
log "数据库已存在,跳过迁移(使用 --reset-db 重建)"
@@ -206,12 +218,12 @@ start_frontend() {
watch_processes() {
while true; do
if ! kill -0 "${BACKEND_PID}" >/dev/null 2>&1; then
if [[ -n "${BACKEND_PID}" ]] && ! kill -0 "${BACKEND_PID}" >/dev/null 2>&1; then
wait "${BACKEND_PID}" || true
printf "后端进程已退出\n" >&2
exit 1
fi
if ! kill -0 "${FRONTEND_PID}" >/dev/null 2>&1; then
if [[ -n "${FRONTEND_PID}" ]] && ! kill -0 "${FRONTEND_PID}" >/dev/null 2>&1; then
wait "${FRONTEND_PID}" || true
printf "前端进程已退出\n" >&2
exit 1
@@ -222,8 +234,12 @@ watch_processes() {
main() {
need_cmd docker
need_cmd go
need_cmd npm
if [[ "${SEED_ONLY}" != "1" && "${NO_BACKEND}" == "0" ]]; then
need_cmd go
fi
if [[ "${SEED_ONLY}" != "1" && "${NO_FRONTEND}" == "0" ]]; then
need_cmd npm
fi
trap cleanup EXIT
trap 'cleanup; exit 130' INT TERM
@@ -252,8 +268,16 @@ main() {
fi
log_success "开发环境已启动"
log "前台:http://localhost:5173"
log "台:http://localhost:5173/admin/loginadmin / admin123456"
if [[ "${NO_FRONTEND}" == "0" ]]; then
log "台:http://localhost:5173"
log "后台:http://localhost:5173/admin/loginadmin / admin123456"
fi
if [[ -z "${BACKEND_PID}" && -z "${FRONTEND_PID}" ]]; then
log "仅启动 Docker 开发依赖,前后端未启动"
return 0
fi
log "按 Ctrl+C 停止前后端;Docker 依赖会保留运行"
watch_processes