修复了 ORM 直接编辑区域的表格高度,实现了工单卡片化和 Saga 模式管理页
This commit is contained in:
1 parent
611aef54bd
commit
62a96af74b
12 files changed
+477
-68
No files matched your search
Generated
+5
-5
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -177,7 +177,9 @@ export interface ColumnSchema {
|
||||
/** 表单中隐藏 */
|
||||
hideInForm?: boolean
|
||||
/** 仅在新增表单显示,编辑表单隐藏(虚拟级联列等场景) */
|
||||
hideInEditForm?: boolean
|
||||
hideInEditForm?: boolean
|
||||
/** 仅在编辑表单显示,新增表单隐藏(如工单"解决方案"——处理时才填) */
|
||||
hideInCreateForm?: boolean
|
||||
/** 是否必填 */
|
||||
required?: boolean
|
||||
/** 必填但允许提交空值(原生特有,跳过 required 校验) */
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user