Kaynağa Gözat

1. 新增了全局对话框 composable(useDialog)及 AppConfirm、AppToast 组件,提供基于 Promise 的确认框与多类型 Toast 通知,替代浏览器原生 alert/confirm;
2. 改造了 App.vue、CategoryTree.vue、PortKillDialog.vue、ServiceFormDialog.vue 中所有原生弹窗调用,统一使用新的 confirm/toast,并补充了删除、保存等关键操作的正向反馈。

weisijie 2 ay önce
ebeveyn
işleme
a8c5059a1d

+ 22 - 7
frontend/src/App.vue

@@ -7,6 +7,11 @@ import ServiceFormDialog from './components/ServiceFormDialog.vue'
 import LogDialog from './components/LogDialog.vue'
 import PortKillDialog from './components/PortKillDialog.vue'
 import CategoryTree from './components/CategoryTree.vue'
+import AppConfirm from './components/AppConfirm.vue'
+import AppToast from './components/AppToast.vue'
+import { useDialog } from './composables/useDialog'
+
+const { confirm, toast } = useDialog()
 
 const services = ref<ServiceConfig[]>([])
 const loading = ref(false)
@@ -75,12 +80,18 @@ const handleEdit = (service: ServiceConfig) => {
 }
 
 const handleDelete = async (id: string) => {
-  if (!confirm('确定要删除此服务吗?')) return
+  const ok = await confirm({
+    message: '确定要删除此服务吗?',
+    danger: true,
+    confirmText: '删除',
+  })
+  if (!ok) return
   try {
     await serviceApi.delete(id)
     await fetchServices()
+    toast('服务已删除', 'success')
   } catch (e) {
-    alert('删除失败: ' + (e as Error).message)
+    toast('删除失败:' + (e as Error).message, 'error')
   }
 }
 
@@ -90,7 +101,7 @@ const handleStart = async (id: string) => {
     await fetchServices()
   } catch (e: any) {
     const msg = e?.response?.data?.error || e.message
-    alert('启动失败: ' + msg)
+    toast('启动失败:' + msg, 'error')
   }
 }
 
@@ -100,7 +111,7 @@ const handleStop = async (id: string) => {
     await fetchServices()
   } catch (e: any) {
     const msg = e?.response?.data?.error || e.message
-    alert('停止失败: ' + msg)
+    toast('停止失败:' + msg, 'error')
   }
 }
 
@@ -126,7 +137,7 @@ const handleStartAll = async () => {
     await serviceApi.startAll()
     await fetchServices()
   } catch (e: any) {
-    alert('一键启动失败: ' + e.message)
+    toast('一键启动失败:' + e.message, 'error')
   } finally {
     loading.value = false
   }
@@ -138,7 +149,7 @@ const handleStopAll = async () => {
     await serviceApi.stopAll()
     await fetchServices()
   } catch (e: any) {
-    alert('一键停止失败: ' + e.message)
+    toast('一键停止失败:' + e.message, 'error')
   } finally {
     loading.value = false
   }
@@ -153,9 +164,10 @@ const handleFormSave = async (data: ServiceConfig) => {
     }
     showFormDialog.value = false
     await fetchServices()
+    toast('保存成功', 'success')
   } catch (e: any) {
     const msg = e?.response?.data?.error || e.message
-    alert('保存失败: ' + msg)
+    toast('保存失败:' + msg, 'error')
   }
 }
 
@@ -277,6 +289,9 @@ onUnmounted(() => {
       :service-name="portServiceName"
       @killed="handlePortKilled"
     />
+
+    <AppConfirm />
+    <AppToast />
   </div>
 </template>
 

+ 84 - 0
frontend/src/components/AppConfirm.vue

@@ -0,0 +1,84 @@
+<script setup lang="ts">
+import { useDialog } from '../composables/useDialog'
+
+const { confirmState, resolveConfirm } = useDialog()
+
+const handleConfirm = () => resolveConfirm(true)
+const handleCancel = () => resolveConfirm(false)
+const handleMaskClick = () => resolveConfirm(false)
+</script>
+
+<template>
+  <Teleport to="body">
+    <div v-if="confirmState.visible" class="confirm-mask" @click.self="handleMaskClick">
+      <div class="confirm-content" role="dialog" aria-modal="true">
+        <div class="confirm-header">
+          <h2>{{ confirmState.title || '请确认' }}</h2>
+        </div>
+        <div class="confirm-body">
+          <p class="confirm-message">{{ confirmState.message }}</p>
+        </div>
+        <div class="confirm-footer">
+          <button class="btn btn-cancel" @click="handleCancel">
+            {{ confirmState.cancelText || '取消' }}
+          </button>
+          <button
+            class="btn"
+            :class="confirmState.danger ? 'btn-danger' : 'btn-submit'"
+            @click="handleConfirm"
+          >
+            {{ confirmState.confirmText || '确认' }}
+          </button>
+        </div>
+      </div>
+    </div>
+  </Teleport>
+</template>
+
+<style scoped>
+.confirm-mask {
+  position: fixed; inset: 0; background: rgba(0,0,0,0.5);
+  display: flex; justify-content: center; align-items: center;
+  z-index: 2000; animation: fade-in 0.15s;
+}
+@keyframes fade-in {
+  from { opacity: 0; }
+  to { opacity: 1; }
+}
+.confirm-content {
+  background: #fff; border-radius: 16px; width: 420px; max-width: 92vw;
+  box-shadow: 0 20px 60px rgba(0,0,0,0.2);
+  animation: pop-in 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
+}
+@keyframes pop-in {
+  from { transform: scale(0.92); opacity: 0; }
+  to { transform: scale(1); opacity: 1; }
+}
+.confirm-header {
+  padding: 18px 24px 8px;
+}
+.confirm-header h2 {
+  font-size: 17px; font-weight: 600; margin: 0; color: #1a1a2e;
+}
+.confirm-body {
+  padding: 4px 24px 20px;
+}
+.confirm-message {
+  font-size: 14px; color: #4b5563; margin: 0; line-height: 1.6;
+  white-space: pre-wrap; word-break: break-word;
+}
+.confirm-footer {
+  padding: 12px 20px 20px;
+  display: flex; justify-content: flex-end; gap: 10px;
+}
+.btn {
+  padding: 8px 18px; border-radius: 8px; font-size: 14px; font-weight: 500;
+  cursor: pointer; border: none; transition: background 0.15s;
+}
+.btn-cancel { background: #f3f4f6; color: #4b5563; }
+.btn-cancel:hover { background: #e5e7eb; }
+.btn-submit { background: #4f46e5; color: #fff; }
+.btn-submit:hover { background: #4338ca; }
+.btn-danger { background: #ef4444; color: #fff; }
+.btn-danger:hover { background: #dc2626; }
+</style>

+ 70 - 0
frontend/src/components/AppToast.vue

@@ -0,0 +1,70 @@
+<script setup lang="ts">
+import { useDialog } from '../composables/useDialog'
+
+const { toastState, dismissToast } = useDialog()
+
+const ICONS: Record<string, string> = {
+  success: 'pi-check-circle',
+  error: 'pi-times-circle',
+  warning: 'pi-exclamation-triangle',
+  info: 'pi-info-circle',
+}
+</script>
+
+<template>
+  <Teleport to="body">
+    <div class="toast-container">
+      <TransitionGroup name="toast">
+        <div
+          v-for="item in toastState.items"
+          :key="item.id"
+          class="toast-item"
+          :class="`toast-${item.type}`"
+          @click="dismissToast(item.id)"
+        >
+          <i class="pi" :class="ICONS[item.type]"></i>
+          <span class="toast-message">{{ item.message }}</span>
+        </div>
+      </TransitionGroup>
+    </div>
+  </Teleport>
+</template>
+
+<style scoped>
+.toast-container {
+  position: fixed; top: 20px; right: 20px; z-index: 3000;
+  display: flex; flex-direction: column; gap: 10px;
+  max-width: 380px;
+}
+.toast-item {
+  display: flex; align-items: center; gap: 10px;
+  padding: 12px 16px; border-radius: 10px;
+  background: #fff; box-shadow: 0 6px 24px rgba(0,0,0,0.12);
+  border-left: 4px solid #6b7280; cursor: pointer;
+  font-size: 14px; color: #374151;
+}
+.toast-item .pi { font-size: 18px; }
+.toast-success { border-left-color: #10b981; }
+.toast-success .pi { color: #10b981; }
+.toast-error { border-left-color: #ef4444; }
+.toast-error .pi { color: #ef4444; }
+.toast-warning { border-left-color: #f59e0b; }
+.toast-warning .pi { color: #f59e0b; }
+.toast-info { border-left-color: #4f46e5; }
+.toast-info .pi { color: #4f46e5; }
+.toast-message { flex: 1; line-height: 1.5; word-break: break-word; }
+
+/* TransitionGroup 动画 */
+.toast-enter-active, .toast-leave-active {
+  transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
+}
+.toast-enter-from {
+  opacity: 0; transform: translateX(40px);
+}
+.toast-leave-to {
+  opacity: 0; transform: translateX(40px);
+}
+.toast-leave-active {
+  position: absolute; right: 0;
+}
+</style>

+ 14 - 4
frontend/src/components/CategoryTree.vue

@@ -2,6 +2,9 @@
 import { ref, computed } from 'vue'
 import type { Category } from '../api/types'
 import { categoryApiObj } from '../api/service'
+import { useDialog } from '../composables/useDialog'
+
+const { confirm, toast } = useDialog()
 
 const props = defineProps<{
   serviceCounts: Record<string, number>
@@ -183,16 +186,23 @@ const deleteCategory = async (id: string) => {
   const cat = categories.value.find(c => c.id === id)
   if (!cat) return
   const childCount = categories.value.filter(c => c.parentId === id).length
-  const msg = childCount > 0
+  const message = childCount > 0
     ? `分类"${cat.name}"下还有 ${childCount} 个子分类,删除后子分类将变为根分类。确定删除?`
     : `确定删除分类"${cat.name}"?`
-  if (!confirm(msg)) return
+  const ok = await confirm({
+    title: '删除分类',
+    message,
+    danger: true,
+    confirmText: '删除',
+  })
+  if (!ok) return
   try {
     await categoryApiObj.delete(id)
     await fetchCategories()
     if (selectedId.value === id) selectCategory(null)
-  } catch (e) {
-    console.error('删除分类失败', e)
+    toast('分类已删除', 'success')
+  } catch (e: any) {
+    toast('删除分类失败:' + (e?.message || '未知错误'), 'error')
   }
 }
 

+ 13 - 5
frontend/src/components/PortKillDialog.vue

@@ -2,6 +2,9 @@
 import { ref, watch } from 'vue'
 import type { PortCheckResult } from '../api/types'
 import { serviceApi } from '../api/service'
+import { useDialog } from '../composables/useDialog'
+
+const { confirm, toast } = useDialog()
 
 const props = defineProps<{
   visible: boolean
@@ -39,22 +42,27 @@ const checkPorts = async () => {
 }
 
 const handleKill = async (pid: number) => {
-  const confirmed = confirm(`确定要终止进程 PID=${pid} 吗?\n\n此操作不可撤销,该进程将被强制终止。`)
-  if (!confirmed) return
+  const ok = await confirm({
+    title: '终止进程',
+    message: `确定要终止进程 PID=${pid} 吗?\n\n此操作不可撤销,该进程将被强制终止。`,
+    danger: true,
+    confirmText: '终止',
+  })
+  if (!ok) return
 
   killLoading.value = pid
   try {
     const res = await serviceApi.killPortProcess(props.serviceId, pid)
     if (res.success) {
-      alert(res.message)
+      toast(res.message, 'success')
       // 重新检查端口
       await checkPorts()
       emit('killed')
     } else {
-      alert('操作失败: ' + res.message)
+      toast('操作失败:' + res.message, 'error')
     }
   } catch (e: any) {
-    alert('操作失败: ' + (e?.response?.data?.error || e.message))
+    toast('操作失败:' + (e?.response?.data?.error || e.message), 'error')
   } finally {
     killLoading.value = null
   }

+ 5 - 2
frontend/src/components/ServiceFormDialog.vue

@@ -2,6 +2,9 @@
 import { ref, watch, computed } from 'vue'
 import type { ServiceConfig, TerminalType, Category } from '../api/types'
 import { systemApi } from '../api/service'
+import { useDialog } from '../composables/useDialog'
+
+const { toast } = useDialog()
 
 const props = defineProps<{
   visible: boolean
@@ -94,11 +97,11 @@ const categoryOptions = computed(() => {
 
 const handleSubmit = () => {
   if (!form.value.name.trim()) {
-    alert('请输入服务名称')
+    toast('请输入服务名称', 'warning')
     return
   }
   if (!form.value.startupCommand.trim()) {
-    alert('请输入启动命令')
+    toast('请输入启动命令', 'warning')
     return
   }
 

+ 80 - 0
frontend/src/composables/useDialog.ts

@@ -0,0 +1,80 @@
+import { reactive } from 'vue'
+
+// ===== Toast 类型 =====
+export type ToastType = 'success' | 'error' | 'warning' | 'info'
+export interface ToastItem {
+  id: number
+  type: ToastType
+  message: string
+}
+
+// ===== Confirm 类型 =====
+export interface ConfirmOptions {
+  title?: string
+  message: string
+  confirmText?: string
+  cancelText?: string
+  /** 危险操作(确认按钮显示为红色) */
+  danger?: boolean
+}
+
+interface ConfirmState extends ConfirmOptions {
+  visible: boolean
+  resolve?: (value: boolean) => void
+}
+
+// ===== 全局响应式状态(单例) =====
+const toastState = reactive<{ items: ToastItem[] }>({ items: [] })
+const confirmState = reactive<ConfirmState>({
+  visible: false,
+  message: '',
+})
+
+let toastId = 0
+
+/** 显示一条 toast 消息,默认 3 秒后自动消失 */
+function toast(message: string, type: ToastType = 'info', duration = 3000) {
+  const id = ++toastId
+  toastState.items.push({ id, type, message })
+  if (duration > 0) {
+    setTimeout(() => dismissToast(id), duration)
+  }
+  return id
+}
+
+/** 手动移除某条 toast */
+function dismissToast(id: number) {
+  const idx = toastState.items.findIndex(t => t.id === id)
+  if (idx >= 0) toastState.items.splice(idx, 1)
+}
+
+/** 弹出确认框,返回 Promise<boolean> */
+function confirm(options: ConfirmOptions): Promise<boolean> {
+  confirmState.title = options.title
+  confirmState.message = options.message
+  confirmState.confirmText = options.confirmText
+  confirmState.cancelText = options.cancelText
+  confirmState.danger = options.danger ?? false
+  confirmState.visible = true
+  return new Promise<boolean>(resolve => {
+    confirmState.resolve = resolve
+  })
+}
+
+/** 内部使用:用户点击确认/取消时调用 */
+function resolveConfirm(value: boolean) {
+  confirmState.visible = false
+  confirmState.resolve?.(value)
+  confirmState.resolve = undefined
+}
+
+export function useDialog() {
+  return {
+    toast,
+    dismissToast,
+    confirm,
+    resolveConfirm,
+    toastState,
+    confirmState,
+  }
+}