修复了 ORM 直接编辑区域的表格高度,实现了工单卡片化和 Saga 模式管理页

This commit is contained in:
TermiNexus committed 2026-08-06 17:55:37 +08:00
1 parent 611aef54bd
commit 62a96af74b
12 files changed
+477 -68

No files matched your search

+5 -5
View File
@@ -26,7 +26,7 @@
"@vitejs/plugin-vue": "^6.0.8",
"@vue/tsconfig": "^0.9.1",
"sass-embedded": "^1.100.0",
"tinymce": "^8.8.2",
"tinymce": "^6.8.5",
"typescript": "~6.0.2",
"unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^32.1.0",
@@ -3130,11 +3130,11 @@
}
},
"node_modules/tinymce": {
"version": "8.8.2",
"resolved": "https://registry.npmmirror.com/tinymce/-/tinymce-8.8.2.tgz",
"integrity": "sha512-wcUGaaAf4XFrQtbZtClbDN/QCqklXLlPt3+7UWliEoosuhnTOJUkXwf7Fzlq7agPnwkKKp1e4JGOhx3O1gWH3g==",
"version": "6.8.5",
"resolved": "https://registry.npmmirror.com/tinymce/-/tinymce-6.8.5.tgz",
"integrity": "sha512-qAL/FxL7cwZHj4BfaF818zeJJizK9jU5IQzTcSLL4Rj5MaJdiVblEj7aDr80VCV1w9h4Lak9hlnALhq/kVtN1g==",
"dev": true,
"license": "SEE LICENSE IN license.md"
"license": "MIT"
},
"node_modules/to-regex-range": {
"version": "5.0.1",
+1 -1
View File
@@ -27,7 +27,7 @@
"@vitejs/plugin-vue": "^6.0.8",
"@vue/tsconfig": "^0.9.1",
"sass-embedded": "^1.100.0",
"tinymce": "^8.8.2",
"tinymce": "^6.8.5",
"typescript": "~6.0.2",
"unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^32.1.0",
+121 -14
View File
@@ -1,6 +1,6 @@
<script setup lang="ts" generic="T extends Record<string, unknown>">
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { ref, computed, watch, onBeforeUnmount } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useCrudTable, type UseCrudTableOptions } from '@/composables/useCrudTable'
import type { ColumnSchema } from '@/types/api'
import { formatUtcTime, nowUtcString } from '@/utils/dateTime'
@@ -80,9 +80,16 @@ const props = defineProps<{
}>()
// 表格实际使用的最大高度:显式 maxHeight 优先;
// stretch(整页列表)场景默认取视口高度 60%,保证表格与卡片一体、
// stretch(整页列表)场景默认取视口高度 55%,保证表格与卡片一体、
// 不产生页面级滚动栏(行多时表格内部滚动)
const tableMaxHeight = computed(() => props.maxHeight ?? (props.stretch ? Math.floor(window.innerHeight * 0.6) : undefined))
const tableMaxHeight = computed(() => props.maxHeight ?? (props.stretch ? Math.floor(window.innerHeight * 0.55) : undefined))
// 表格模式高度绑定:
// - stretch(整页列表):用固定 height(= 视口 55%),行多时表格内部滚动,高度恒定
// - 非 stretch(详情页嵌套):用 max-height,行数少则自适应内容高度,多则限制滚动
// 拆成两个 computed 单独绑定(避免模板直接引用 props.stretch 触发泛型组件解析缺陷)
const tableFixedHeight = computed(() => (props.stretch ? tableMaxHeight.value : undefined))
const tableLimitHeight = computed(() => (props.stretch ? undefined : tableMaxHeight.value))
const options: UseCrudTableOptions<T> = {
table: props.table,
@@ -149,7 +156,7 @@ const tableColumns = computed(() =>
)
// 弹窗表单可见列(排除隐藏/主键隐藏/不可编辑/divider/html/父级注入字段;
// 编辑态额外排除 hideInEditForm 的虚拟级联列)
// 编辑态额外排除 hideInEditForm 的虚拟级联列;新增态额外排除 hideInCreateForm 的"仅编辑"列)
// 自动时间戳字段(CreateTime 等)强制 required=false:由服务器填写,不要求用户输入
const formColumns = computed(() => {
const pkField = columns.value.find((c) => c.isPrimary || c.primaryKey)?.field || 'Id'
@@ -159,6 +166,7 @@ const formColumns = computed(() => {
c.editable !== false &&
!c.hideInForm &&
!(isEdit.value && c.hideInEditForm) &&
!(!isEdit.value && c.hideInCreateForm) &&
c.type !== 'hidden' &&
c.type !== 'divider' &&
c.type !== 'html' &&
@@ -168,9 +176,66 @@ const formColumns = computed(() => {
.map((c: ColumnSchema) => (isAutoTimestampField(c.field) ? { ...c, required: false } : c))
})
// ===== 新增表单自动草稿(防止误关闭/误刷新丢失录入内容) =====
// 按表维度存 localStorage:录入内容每 600ms 防抖自动保存;
// 再次打开"新增"时提示恢复或放弃;提交成功后清除。
const draftKey = computed(() => `gerp-draft:add:${props.table}`)
interface FormDraft {
data: Record<string, unknown>
updatedAt: number
}
function readDraft(): FormDraft | null {
try {
const raw = localStorage.getItem(draftKey.value)
return raw ? (JSON.parse(raw) as FormDraft) : null
} catch {
return null
}
}
function clearDraft(): void {
localStorage.removeItem(draftKey.value)
}
let draftTimer: ReturnType<typeof setTimeout> | null = null
/** 打开新增弹窗时的表单基线(JSON),内容未改动时不写草稿,避免"空草稿"打扰 */
let draftBaseline: string | null = null
function writeDraft(): void {
if (!dialogVisible.value || isEdit.value) return
try {
const cur = JSON.stringify({ ...editingRow })
if (draftBaseline !== null && cur === draftBaseline) return // 未录入任何内容,不存
localStorage.setItem(
draftKey.value,
JSON.stringify({ data: { ...editingRow }, updatedAt: Date.now() } satisfies FormDraft),
)
} catch {
/* 隐私模式/配额满时静默忽略 */
}
}
onBeforeUnmount(() => {
if (draftTimer) clearTimeout(draftTimer)
})
// 弹窗内表单内容变化 → 防抖自动存草稿(仅新增态;基线未变不写)
watch(
() => ({ ...editingRow }),
() => {
if (!dialogVisible.value || isEdit.value) return
if (draftTimer) clearTimeout(draftTimer)
draftTimer = setTimeout(writeDraft, 600)
},
{ deep: true },
)
// 新增:先走 useCrudTable 初始化,再注入父级上下文关联字段;
// 自动时间戳字段(CreateTime 等)预填当前 UTC 时间——不要求用户输入(后端仍以服务器时间为准)
function onHandleCreate(): void {
// 自动时间戳字段(CreateTime 等)预填当前 UTC 时间——不要求用户输入(后端仍以服务器时间为准);
// 若存在未提交草稿,提示恢复或放弃
async function onHandleCreate(): Promise<void> {
handleCreate()
Object.assign(editingRow, inheritedQueryFields.value)
for (const col of columns.value) {
@@ -178,12 +243,33 @@ function onHandleCreate(): void {
editingRow[col.field] = nowUtcString()
}
}
// 记录表单基线:未录入内容时不落盘草稿
draftBaseline = JSON.stringify({ ...editingRow })
const draft = readDraft()
if (!draft || !draft.data || Object.keys(draft.data).length === 0) return
try {
await ElMessageBox.confirm(
`检测到 ${new Date(draft.updatedAt).toLocaleString()} 未提交的草稿,是否恢复继续编辑?`,
'恢复草稿',
{ type: 'warning', confirmButtonText: '恢复草稿', cancelButtonText: '放弃' },
)
Object.assign(editingRow, draft.data)
draftBaseline = null // 恢复后允许继续增量保存
} catch {
clearDraft() // 用户放弃 → 清掉旧草稿
}
}
// 提交:保证父级上下文关联字段始终存在(编辑时用户可能误删)
function onHandleSubmit(): void {
// 提交:保证父级上下文关联字段始终存在(编辑时用户可能误删);
// 提交成功即清除草稿,失败保留(拦截器已提示)
async function onHandleSubmit(): Promise<void> {
Object.assign(editingRow, inheritedQueryFields.value)
handleSubmit()
try {
await handleSubmit()
clearDraft()
} catch {
// 失败:保留草稿供再次提交
}
}
// 查询条件变化(如工单管理页的状态筛选):重置到第一页并重新加载。
@@ -330,6 +416,11 @@ function columnTitle(field: string): string {
return columns.value.find((c) => c.field === field)?.title || field
}
// 卡片字段控件类型:用于卡片正文对 select/status 字段渲染标签
function cardFieldType(field: string): string | undefined {
return columns.value.find((c) => c.field === field)?.type
}
// 卡片复选:同步到 selectedRows(对齐 vxe-table checkbox)
function onCardCheck(row: T, checked: boolean): void {
const rows = checked
@@ -466,8 +557,8 @@ function isCardSelected(row: Record<string, unknown>): boolean {
border
stripe
round
:height="'auto'"
:max-height="tableMaxHeight"
:height="tableFixedHeight"
:max-height="tableLimitHeight"
:row-config="{ isHover: true }"
:column-config="{ resizable: true }"
:checkbox-config="{ range: true }"
@@ -608,7 +699,15 @@ function isCardSelected(row: Record<string, unknown>): boolean {
class="crud-card-field"
>
<span class="crud-card-field-label">{{ columnTitle(field) }}</span>
<span class="crud-card-field-value" :title="formatCardValue(node.row as Record<string, unknown>, field)">
<!-- select/status:标签渲染(状态/优先级一目了然) -->
<el-tag
v-if="cardFieldType(field) === 'select' || cardFieldType(field) === 'status'"
size="small"
effect="plain"
>
{{ formatCardValue(node.row as Record<string, unknown>, field) }}
</el-tag>
<span v-else class="crud-card-field-value" :title="formatCardValue(node.row as Record<string, unknown>, field)">
{{ formatCardValue(node.row as Record<string, unknown>, field) }}
</span>
</div>
@@ -674,7 +773,15 @@ function isCardSelected(row: Record<string, unknown>): boolean {
class="crud-card-field"
>
<span class="crud-card-field-label">{{ columnTitle(field) }}</span>
<span class="crud-card-field-value" :title="formatCardValue(row as Record<string, unknown>, field)">
<!-- select/status:标签渲染(状态/优先级一目了然) -->
<el-tag
v-if="cardFieldType(field) === 'select' || cardFieldType(field) === 'status'"
size="small"
effect="plain"
>
{{ formatCardValue(row as Record<string, unknown>, field) }}
</el-tag>
<span v-else class="crud-card-field-value" :title="formatCardValue(row as Record<string, unknown>, field)">
{{ formatCardValue(row as Record<string, unknown>, field) }}
</span>
</div>
+16 -26
View File
@@ -3,31 +3,15 @@
* RichTextEditor 富文本编辑器
*
* 基于 @tinymce/tinymce-vue + tinymce(npm 自托管,离线可用)。
* 打包要点:
* - 显式导入 model/theme/icons 与所需 plugins(tinymce 8 不会自动加载)
* - skin:false + content_css:false:皮肤/内容样式通过 import CSS 直接打进产物,
* 避免运行时按 baseURL 加载皮肤资源失败
* - @tinymce/tinymce-vue 通过 window.tinymce 获取实例,导入后手动挂到全局
*
* 加载方式(自托管,规避打包器对 tinymce UMD 的处理问题):
* - tinymce.min.js + 插件/主题/皮肤由 vite-plugin-static-copy 在构建时拷到 dist/tinymce,
* 部署后经 /tinymce/ 提供访问;
* - 编辑器运行时通过 <script> 标签加载 tinymce.min.js(tinymce-script-src),
* 插件/主题/皮肤按 base_url 自动加载,不进入打包器产物(不产生 "tinymce is not defined")。
*/
import { computed } from 'vue'
import Editor from '@tinymce/tinymce-vue'
import tinymce from 'tinymce'
import 'tinymce/models/dom'
import 'tinymce/themes/silver'
import 'tinymce/icons/default'
import 'tinymce/plugins/advlist'
import 'tinymce/plugins/lists'
import 'tinymce/plugins/link'
import 'tinymce/plugins/image'
import 'tinymce/plugins/table'
import 'tinymce/plugins/code'
import 'tinymce/plugins/wordcount'
import 'tinymce/plugins/autoresize'
import 'tinymce/skins/ui/oxide/skin.css'
import 'tinymce/skins/content/default/content.css'
// @tinymce/tinymce-vue 依赖全局 tinymce(见其 TinyMCE.js:global.tinymce)
;(window as unknown as { tinymce: typeof tinymce }).tinymce = tinymce
const props = withDefaults(
defineProps<{
@@ -47,10 +31,11 @@ const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
/** tinymce init 配置(皮肤/内容样式已打包,禁用运行时加载) */
/** tinymce init 配置:资源路径指向部署后的 /tinymce/ 目录 */
const editorInit = computed(() => ({
skin: false,
content_css: false,
base_url: '/tinymce',
skin_url: '/tinymce/skins/ui/oxide',
content_css: '/tinymce/skins/content/default/content.min.css',
height: Math.max((props.rows || 8) * 40, 200),
menubar: false,
branding: false,
@@ -70,5 +55,10 @@ function onChange(content: string): void {
</script>
<template>
<Editor :model-value="modelValue" :init="editorInit" @update:model-value="onChange" />
<Editor
:model-value="modelValue"
:init="editorInit"
tinymce-script-src="/tinymce/tinymce.min.js"
@update:model-value="onChange"
/>
</template>
+6
View File
@@ -229,6 +229,12 @@ export const staticRoutes: RouteRecordRaw[] = [
component: () => import('@/views/system/Database.vue'),
meta: { title: '数据库管理', requiresAuth: true, menuGuarded: true },
},
{
path: 'system/msync-servers',
name: 'SystemMSyncServers',
component: () => import('@/views/system/MSyncServers.vue'),
meta: { title: '服务器管理', requiresAuth: true },
},
{
path: 'system/dbtable',
name: 'SystemDbTable',
+3 -1
View File
@@ -177,7 +177,9 @@ export interface ColumnSchema {
/** 表单中隐藏 */
hideInForm?: boolean
/** 仅在新增表单显示,编辑表单隐藏(虚拟级联列等场景) */
hideInEditForm?: boolean
hideInEditForm?: boolean
/** 仅在编辑表单显示,新增表单隐藏(如工单"解决方案"——处理时才填) */
hideInCreateForm?: boolean
/** 是否必填 */
required?: boolean
/** 必填但允许提交空值(原生特有,跳过 required 校验) */
+10 -9
View File
@@ -19,22 +19,23 @@
* - 返回服务器列表改用 router.push(替代 GERP.Navigate)
*/
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { get } from '@/api/request'
import { setPageTitle, getServer, isDefaultServer } from '@/utils/serverScope'
import { setPageTitle, DEFAULT_SERVER } from '@/utils/serverScope'
import { useUserStore } from '@/stores/user'
import AdminBadge from '@/components/AdminBadge.vue'
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const isAdmin = computed(() => userStore.isAdmin)
// 无权访问:CheckAdmin 加载完成且确认非管理员(加载中不显示无权访问,避免刷新后闪误判)
const denied = computed(() => userStore.adminChecked && !isAdmin.value)
/** 服务器标识(主实例为 gvsds-master) */
const serverName = getServer()
/** 服务器标识:优先 URL query(子服务器场景由 msync-servers 等入口带入),缺省主实例 */
const serverName = computed(() => (route.query.server as string) || DEFAULT_SERVER)
/** 是否主实例 */
const isMain = isDefaultServer()
const isMain = computed(() => serverName.value === DEFAULT_SERVER)
/** 表项(对齐后端字段) */
interface TableItem {
@@ -51,7 +52,7 @@ const staticTables = ref<TableItem[]>([])
const dynamicTables = ref<TableItem[]>([])
/** 页面标题 */
const pageTitle = computed(() => (isMain ? '数据库管理' : `${serverName} — 数据库管理`))
const pageTitle = computed(() => (isMain.value ? '数据库管理' : `${serverName.value} — 数据库管理`))
setPageTitle(pageTitle.value)
@@ -74,7 +75,7 @@ async function loadMainTables(): Promise<void> {
async function loadSubTables(): Promise<void> {
loading.value = true
try {
const data = await get<TableItem[]>(`/api/gerp/msync/servers/${encodeURIComponent(serverName)}/form/`, {
const data = await get<TableItem[]>(`/api/gerp/msync/servers/${encodeURIComponent(serverName.value)}/form/`, {
Action: 'tables',
})
dynamicTables.value = Array.isArray(data) ? data : []
@@ -91,7 +92,7 @@ function viewTable(table: TableItem): void {
table: table.name,
title: table.title || table.name,
}
if (!isMain) query.server = serverName
if (!isMain.value) query.server = serverName.value
router.push({ path: '/system/dbtable', query })
}
@@ -128,7 +129,7 @@ onMounted(() => {
/** CheckAdmin 确认管理员后加载数据(非管理员不加载,展示无权访问) */
function loadIfAdmin(): void {
if (!isAdmin.value) return
if (isMain) {
if (isMain.value) {
loadMainTables()
} else {
loadSubTables()
+9 -9
View File
@@ -22,7 +22,7 @@
*/
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { setPageTitle, getServer, isDefaultServer } from '@/utils/serverScope'
import { setPageTitle, DEFAULT_SERVER } from '@/utils/serverScope'
import { useUserStore } from '@/stores/user'
import AdminBadge from '@/components/AdminBadge.vue'
import CrudTable from '@/components/CrudTable.vue'
@@ -34,29 +34,29 @@ const isAdmin = computed(() => userStore.isAdmin)
// 无权访问:CheckAdmin 加载完成且确认非管理员(加载中不显示无权访问,避免刷新后闪误判)
const denied = computed(() => userStore.adminChecked && !isAdmin.value)
/** 服务器标识(主实例为 gvsds-master) */
const serverName = getServer()
/** 服务器标识:优先 URL query(子服务器场景由数据库管理页带入),缺省主实例 */
const serverName = computed(() => (route.query.server as string) || DEFAULT_SERVER)
/** 是否主实例 */
const isMain = isDefaultServer()
const isMain = computed(() => serverName.value === DEFAULT_SERVER)
/** 表名(路由 query,computed 响应式:同一 tab 复用且 query 变化时自动更新) */
const tableName = computed(() => (route.query.table as string) || '')
/** 表标题(路由 query,缺省回退表名) */
const tableTitle = computed(() => (route.query.title as string) || tableName.value)
/** 页面标题 */
const pageTitle = computed(() => (isMain ? tableTitle.value : `${serverName} — ${tableTitle.value}`))
const pageTitle = computed(() => (isMain.value ? tableTitle.value : `${serverName.value} — ${tableTitle.value}`))
setPageTitle(pageTitle.value)
/** 表格最大高度:视口高度 60%(窗口尺寸变化时响应式更新) */
const tableMaxHeight = ref(Math.floor(window.innerHeight * 0.6))
/** 表格最大高度:视口高度 55%(窗口尺寸变化时响应式更新) */
const tableMaxHeight = ref(Math.floor(window.innerHeight * 0.55))
window.addEventListener('resize', () => {
tableMaxHeight.value = Math.floor(window.innerHeight * 0.6)
tableMaxHeight.value = Math.floor(window.innerHeight * 0.55)
})
/** 返回数据库管理页 */
function goBack(): void {
const query: Record<string, string> = {}
if (!isMain) query.server = serverName
if (!isMain.value) query.server = serverName.value
router.push({ path: '/system/database', query })
}
+270
View File
@@ -0,0 +1,270 @@
<script setup lang="ts">
/**
* 服务器管理(MSyncServers)
*
* 修复:Database 子服务器视图"返回服务器列表"跳转 /system/msync-servers 曾 404。
* 本页对齐后端 /api/gerp/msync/servers/(MSyncServerListView):
* - 展示 MSYNC 已注册服务器(主/工作节点、状态、域名、心跳时间)
* - 主节点(gvsds-master)标记且不可注销
* - 点击"管理"进入该服务器的数据库管理(/system/database?server=xxx,主实例不带参数)
* - 工作节点可注销(POST /deregister/,需 admin:saas_server 权限)
*/
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { get, post } from '@/api/request'
import { setPageTitle } from '@/utils/serverScope'
import { formatUtcTime } from '@/utils/dateTime'
import { useUserStore } from '@/stores/user'
import AdminBadge from '@/components/AdminBadge.vue'
const router = useRouter()
const userStore = useUserStore()
const isAdmin = computed(() => userStore.isAdmin)
// 无权访问:CheckAdmin 加载完成且确认非管理员(加载中不显示无权访问,避免刷新后闪误判)
const denied = computed(() => userStore.adminChecked && !isAdmin.value)
setPageTitle('服务器管理')
/** 服务器记录(对齐 MSyncServerListView 返回) */
interface ServerItem {
ServiceName: string
ServiceDomain: string
ServiceType: 'MASTER' | 'WORKER'
ServiceStatus: string
ServiceScope: string[]
ServiceMetadata: Record<string, unknown>
LastHeartbeat?: string | null
IsAlive: boolean
IsMasterNode: boolean
}
const loading = ref(false)
const servers = ref<ServerItem[]>([])
/** 权限不足(admin:saas_server) */
const forbidden = ref(false)
/** 加载服务器列表 */
async function loadServers(): Promise<void> {
loading.value = true
forbidden.value = false
try {
const data = await get<ServerItem[]>('/api/gerp/msync/servers/')
servers.value = Array.isArray(data) ? data : []
} catch (e) {
// 403 → 权限不足提示(拦截器已弹错误,这里仅记录状态)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if ((e as any)?.response?.status === 403) forbidden.value = true
servers.value = []
} finally {
loading.value = false
}
}
/** 管理:进入该服务器数据库管理(主实例不带 server 参数) */
function manageServer(item: ServerItem): void {
if (item.IsMasterNode) {
router.push({ path: '/system/database' })
return
}
router.push({ path: '/system/database', query: { server: item.ServiceName } })
}
/** 注销工作节点(主节点不可注销) */
async function deregisterServer(item: ServerItem): Promise<void> {
if (item.IsMasterNode) return
try {
await ElMessageBox.confirm(
`确定注销服务器「${item.ServiceName}」吗?注销后该服务器将无法继续同步数据。`,
'注销服务器',
{ type: 'warning', confirmButtonText: '确定注销', cancelButtonText: '取消' },
)
} catch {
return
}
try {
await post(`/api/gerp/msync/servers/${encodeURIComponent(item.ServiceName)}/deregister/`)
ElMessage.success(`服务器「${item.ServiceName}」已注销`)
await loadServers()
} catch {
/* 拦截器已提示 */
}
}
onMounted(() => {
// 等 CheckAdmin 加载完成后确认管理员再加载(刷新后 adminChecked 异步恢复)
if (userStore.adminChecked) {
if (isAdmin.value) void loadServers()
} else {
watch(
() => userStore.adminChecked,
(checked) => {
if (checked && isAdmin.value) void loadServers()
},
)
}
})
</script>
<template>
<div class="msync-page">
<AdminBadge />
<el-empty
v-if="denied"
description="无权访问:该页面仅管理员可查看,请切换至管理员角色或联系管理员"
>
<template #image>
<i class="fas fa-lock" style="font-size: 48px; color: var(--el-color-danger)" />
</template>
</el-empty>
<el-empty v-else-if="forbidden" description="权限不足:需要 admin:saas_server 权限查看服务器列表">
<template #image>
<i class="fas fa-shield-halved" style="font-size: 48px; opacity: 0.3" />
</template>
</el-empty>
<template v-else>
<!-- 页头 -->
<div class="msync-header">
<h1 class="page-title">
<i class="fas fa-server icon-primary" />
<span>服务器管理</span>
</h1>
<el-button type="primary" plain :loading="loading" @click="loadServers">
<el-icon><Refresh /></el-icon>刷新
</el-button>
</div>
<!-- 服务器列表 -->
<div v-loading="loading" class="msync-list">
<div v-for="svc in servers" :key="svc.ServiceName" class="msync-item">
<span
class="msync-status"
:class="{ 'is-alive': svc.IsAlive }"
:title="svc.IsAlive ? '在线' : '离线'"
/>
<div class="msync-info">
<div class="msync-name">
{{ svc.ServiceName }}
<el-tag v-if="svc.IsMasterNode" size="small" type="warning" effect="dark">主节点</el-tag>
<el-tag v-else size="small" type="info" effect="plain">工作节点</el-tag>
</div>
<div class="msync-meta">
<span v-if="svc.ServiceDomain">{{ svc.ServiceDomain }}</span>
<span>{{ svc.ServiceStatus }}</span>
<span v-if="svc.LastHeartbeat">心跳 {{ formatUtcTime(svc.LastHeartbeat) }}</span>
</div>
</div>
<div class="msync-actions">
<el-button size="small" type="primary" @click="manageServer(svc)">
<i class="fas fa-database" /> 管理
</el-button>
<el-button
v-if="!svc.IsMasterNode"
size="small"
type="danger"
plain
@click="deregisterServer(svc)"
>
<i class="fas fa-trash-can" /> 注销
</el-button>
</div>
</div>
<el-empty v-if="!loading && servers.length === 0" description="暂无已注册服务器" :image-size="60" />
</div>
</template>
</div>
</template>
<style scoped lang="scss">
.msync-page {
padding: 20px;
height: 100%;
box-sizing: border-box;
}
.msync-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
.page-title {
margin: 0;
font-size: 20px;
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
.icon-primary {
color: var(--el-color-primary);
}
}
}
.msync-list {
min-height: 60px;
}
.msync-item {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color);
border-radius: 8px;
margin-bottom: 10px;
transition: all 0.2s;
&:hover {
border-color: var(--el-color-primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
}
// 在线状态点:绿=在线 灰=离线
.msync-status {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--el-text-color-placeholder);
flex-shrink: 0;
&.is-alive {
background: var(--el-color-success);
box-shadow: 0 0 0 3px var(--el-color-success-light-8);
}
}
.msync-info {
flex: 1;
min-width: 0;
}
.msync-name {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
margin-bottom: 4px;
}
.msync-meta {
display: flex;
align-items: center;
gap: 14px;
font-size: 12px;
color: var(--el-text-color-secondary);
flex-wrap: wrap;
}
.msync-actions {
flex-shrink: 0;
display: flex;
gap: 8px;
}
</style>
+4 -1
View File
@@ -134,7 +134,7 @@ onMounted(() => {
</div>
</div>
<!-- 工单列表 -->
<!-- 工单列表(卡片模式) -->
<CrudTable
table="WorkOrder"
:query="query"
@@ -143,6 +143,9 @@ onMounted(() => {
pageable
stretch
selectable
card-mode
card-title-field="Title"
:card-fields="['Status', 'Priority', 'Category', 'AssigneeUUID', 'CreatorUUID', 'CreateTime']"
:on-view="goDetail"
/>
</div>
+12 -1
View File
@@ -36,7 +36,18 @@ function goDetail(row: Record<string, unknown>): void {
<template>
<div class="wo-my-page">
<CrudTable table="WorkOrder" title="我的工单" :query="query" searchable pageable stretch :selectable="false" :on-view="goDetail" />
<CrudTable
table="WorkOrder"
title="我的工单"
:query="query"
searchable
pageable
stretch
card-mode
card-title-field="Title"
:card-fields="['Status', 'Priority', 'Category', 'AssigneeUUID', 'CreateTime']"
:on-view="goDetail"
/>
</div>
</template>
+20 -1
View File
@@ -1,10 +1,28 @@
import { defineConfig } from 'vite'
import { defineConfig, type Plugin } from 'vite'
import { fileURLToPath, URL } from 'node:url'
import { cpSync } from 'node:fs'
import { resolve } from 'node:path'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
const projectRoot = fileURLToPath(new URL('.', import.meta.url))
/** 富文本编辑器自托管:构建完成后把 tinymce 整目录拷到 dist/tinymce,
* 运行时经 /tinymce/ 以 <script> 标签加载,规避打包器对 tinymce UMD 的处理问题 */
function copyTinymceAssets(): Plugin {
return {
name: 'copy-tinymce-assets',
apply: 'build',
closeBundle() {
const src = resolve(projectRoot, 'node_modules/tinymce')
const dest = resolve(projectRoot, 'dist/tinymce')
cpSync(src, dest, { recursive: true, force: true })
},
}
}
// https://vite.dev/config/
export default defineConfig({
plugins: [
@@ -21,6 +39,7 @@ export default defineConfig({
dts: 'src/types/components.d.ts',
dirs: ['src/components'],
}),
copyTinymceAssets(),
],
resolve: {
alias: {