20260822171825

This commit is contained in:
oneao committed 2026-08-22 17:18:27 +08:00
1 parent c43b4c8364
commit 27a32b313d
187 files changed
+34869 -4741

No files matched your search

+56 -1
View File
@@ -94,6 +94,45 @@ const barStyles = computed(() =>
}),
)
/* ---------- 拖拽移动(固定浮层可能遮挡顶栏操作,允许用户拖开) ---------- */
const rootEl = ref(null)
// 拖动后的 left/top 坐标;null 表示未拖过,使用默认定位(右上角)
const pos = ref(null)
let dragStart = null // { pointerX, pointerY, originX, originY, width, height }
function onPointerDown(e) {
// 重置按钮的点击不触发拖拽
if (e.target.closest('.fps-monitor__reset')) return
const rect = rootEl.value.getBoundingClientRect()
dragStart = {
pointerX: e.clientX,
pointerY: e.clientY,
originX: pos.value ? pos.value.x : rect.left,
originY: pos.value ? pos.value.y : rect.top,
width: rect.width,
height: rect.height,
}
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
e.preventDefault()
}
function onPointerMove(e) {
if (!dragStart) return
const dx = e.clientX - dragStart.pointerX
const dy = e.clientY - dragStart.pointerY
// 边界限制:不拖出视口
const x = Math.max(0, Math.min(dragStart.originX + dx, window.innerWidth - dragStart.width))
const y = Math.max(0, Math.min(dragStart.originY + dy, window.innerHeight - dragStart.height))
pos.value = { x, y }
}
function onPointerUp() {
dragStart = null
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
}
/* ---------- 生命周期 ---------- */
onMounted(() => {
resetSampleWindow()
@@ -108,7 +147,15 @@ onBeforeUnmount(() => {
</script>
<template>
<aside class="fps-monitor" aria-label="FPS 性能监控" v-bind="$attrs">
<aside
ref="rootEl"
class="fps-monitor"
:class="{ 'is-dragging': dragStart }"
:style="pos ? { left: pos.x + 'px', top: pos.y + 'px' } : undefined"
aria-label="FPS 性能监控"
v-bind="$attrs"
@pointerdown="onPointerDown"
>
<div class="fps-monitor__header">
<span class="fps-monitor__label">FPS</span>
<strong class="fps-monitor__current" :style="{ color: currentFpsColor }">
@@ -156,10 +203,18 @@ onBeforeUnmount(() => {
line-height: 1.4;
letter-spacing: 0;
user-select: none;
cursor: move;
touch-action: none;
background: var(--fms-card);
border: 1px solid var(--fms-border);
border-radius: var(--fms-control-radius);
box-shadow: var(--fms-popover-shadow);
transition: box-shadow 150ms ease;
&.is-dragging {
cursor: grabbing;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.18);
}
}
.fps-monitor__header {
@@ -591,7 +591,6 @@ defineExpose({
<template v-else #empty>
<div class="fms-table__empty" role="status">
<strong>暂无数据</strong>
<span>当前没有可显示的内容</span>
</div>
</template>
</StkTable>
@@ -206,6 +206,8 @@
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
/* 4px 内边距为聚焦外发光(box-shadow 3px)留出空间,避免被 overflow 裁剪 */
padding: 4px;
color: var(--fms-text);
word-break: break-word;
}
@@ -0,0 +1,56 @@
import { onBeforeUnmount, onMounted } from 'vue'
// 输入态判断:焦点在 input / textarea / 可编辑区域
function isTypingTarget(e) {
const el = e.target
return !!el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable)
}
// 快捷键匹配:精确匹配(未声明的修饰符按下则不匹配)
function matches(e, s) {
if (e.key.toLowerCase() !== String(s.key).toLowerCase()) return false
// 主修饰键:声明了 mod / ctrl / meta 中任一 → 要求 Ctrl 或 Meta 按下
const needsMod = s.mod === true || s.ctrl === true || s.meta === true
const modPressed = e.ctrlKey || e.metaKey
if (needsMod !== modPressed) return false
// alt / shift 精确匹配(未声明即要求未按下)
if (!!s.alt !== e.altKey) return false
if (!!s.shift !== e.shiftKey) return false
return true
}
/**
* 全局键盘快捷键注册(可组合、可复用)。
*
* 用法(在任意组件 setup 顶层调用):
* useHotkeys([
* { key: 's', mod: true, ignoreWhenTyping: false, handler: save }, // Ctrl/Cmd + S
* { key: '/', handler: focusSearch }, // 单独键(无修饰符)
* { key: 'r', alt: true, handler: reload }, // Alt + R
* ])
*
* 修饰符语义(精确匹配,未声明的修饰符按下则不匹配):
* mod —— 主修饰键:Ctrl 或 Meta(Cmd)任一(跨平台主修饰键)
* ctrl / meta / alt / shift —— 单独指定(与 mod 可叠加,如 { key: 's', mod: true, shift: true })
*
* 其他选项:
* ignoreWhenTyping(默认 true)—— 焦点在 input / textarea / 可编辑区域时忽略;
* 组合保存类快捷键(如 Ctrl+S)通常传 false,保证输入时也能保存。
* handler(e) —— 触发回调,默认已 preventDefault 阻止浏览器默认行为。
*
* 生命周期:onMounted 注册、onBeforeUnmount 卸载,组件销毁后自动解绑。
*/
export function useHotkeys(shortcuts) {
function onKeydown(e) {
for (const s of shortcuts) {
if (!matches(e, s)) continue
if (s.ignoreWhenTyping !== false && isTypingTarget(e)) continue
e.preventDefault()
s.handler(e)
break
}
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
}
@@ -406,8 +406,8 @@ onBeforeUnmount(() => document.removeEventListener('fullscreenchange', syncFulls
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 28px;
height: 28px;
width: 26px;
height: 26px;
border-radius: 8px;
background: color-mix(in srgb, var(--fms-primary) 12%, transparent);
color: var(--fms-primary);
+3 -3
View File
@@ -34,11 +34,11 @@
--fms-primary-active: color-mix(in srgb, var(--fms-primary) 76%, #000);
/* 字体 */
--fms-font-size: 14px;
--fms-line-height: 20px;
--fms-font-size: 13px;
--fms-line-height: 1.4;
/* 控件 */
--fms-control-height: 32px;
--fms-control-height: 28px;
--fms-control-padding-x: 10px;
--fms-control-radius: 6px;
--fms-control-size: 16px;
@@ -96,14 +96,14 @@ defineExpose({ open })
@ok="save"
>
<div class="mm-i18n-quick">
<div class="mm-i18n-quick__key">
<div class="mm-i18n-quick__field">
<label>多语言键</label>
<Input v-model="i18nKey" disabled />
</div>
<div class="mm-i18n-quick__rows">
<div v-for="(item, idx) in entries" :key="idx" class="mm-i18n-quick__row">
<span class="mm-i18n-quick__locale">{{ item.b_name }} ({{ item.b_locale }})</span>
<Input v-model="item.b_value" class="mm-i18n-quick__value" />
<div v-for="(item, idx) in entries" :key="idx" class="mm-i18n-quick__field">
<label>{{ item.b_name }} ({{ item.b_locale }})</label>
<Input v-model="item.b_value" />
</div>
</div>
</div>
@@ -116,35 +116,22 @@ defineExpose({ open })
flex-direction: column;
gap: 12px;
&__key {
display: grid;
grid-template-columns: 88px 1fr;
align-items: center;
gap: 8px;
/* 上下结构:label 在上、输入框在下 */
&__field {
display: flex;
flex-direction: column;
gap: 4px;
label {
font-size: 13px;
color: var(--fms-text-secondary);
text-align: right;
}
}
&__row {
&__rows {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
&__locale {
flex: none;
width: 140px;
color: var(--fms-text-secondary);
font-size: 13px;
}
&__value {
flex: 1;
flex-direction: column;
gap: 10px;
}
}
</style>
@@ -10,8 +10,6 @@ import Select from '@/components/ui/select/select.vue'
import Switch from '@/components/ui/switch/switch.vue'
import Form from '@/components/ui/form/form.vue'
import FormItem from '@/components/ui/form/form-item.vue'
import Tabs from '@/components/ui/tabs/tabs.vue'
import TabPane from '@/components/ui/tabs/tab-pane.vue'
import { buildModuleI18nKey, validateModuleCode } from '@/utils/i18n'
import IconSelect from './IconSelect.vue'
import ModulePowersPanel from './ModulePowersPanel.vue'
@@ -34,6 +32,9 @@ const emit = defineEmits(['update', 'updatePowers', 'i18nSaved'])
const activeSection = ref('basic')
const formData = ref(null)
// 权限点数量(导航项徽标)
const powersCount = computed(() => props.powers?.length ?? 0)
watch(
() => props.menu,
(val) => {
@@ -55,6 +56,16 @@ const isPage = computed(() => formData.value?.b_menu_type === 'page')
const isData = computed(() => formData.value?.b_menu_type === 'data')
const isExternal = computed(() => formData.value?.b_menu_type === 'external')
// 类型变为目录时,若停留在「权限配置」分区(目录不展示该分区)则切回基本信息
watch(
() => formData.value?.b_menu_type,
(type) => {
if (type === 'directory' && activeSection.value === 'powers') {
activeSection.value = 'basic'
}
},
)
/* ---------- 校验规则:错误直接显示在对应 FormItem 下方 ---------- */
const formRules = computed(() => ({
b_code: [
@@ -156,123 +167,162 @@ const i18nItems = computed(() => {
<template>
<div class="mm-menu-detail" v-if="formData">
<Tabs v-model="activeSection" class="mm-menu-tabs">
<TabPane tab-key="basic">
<template #label><Info :size="14" /> 基本信息</template>
<Form ref="formRef" :model="formData" :rules="formRules" layout="vertical" :columns="2" max-width="720px">
<FormItem label="菜单编码" name="b_code" required>
<Input :model-value="formData.b_code" :disabled="loading" @update:model-value="onCodeInput" />
</FormItem>
<FormItem label="菜单名称" name="b_name" required>
<Input
:model-value="formData.b_name"
:disabled="loading"
@update:model-value="(v) => setField('b_name', v)"
/>
</FormItem>
<FormItem label="菜单类型" name="b_menu_type" required>
<Select
:model-value="formData.b_menu_type"
:options="menuTypeOptions"
:disabled="loading"
@update:model-value="(v) => setField('b_menu_type', v)"
/>
</FormItem>
<FormItem label="上级菜单" name="b_parent_id">
<Input :model-value="parentName" readonly />
</FormItem>
<FormItem label="多语言键" name="b_i18n">
<div class="mm-basic__i18n">
<Input :model-value="formData.b_i18n" readonly />
<button
type="button"
class="mm-basic__i18n-btn"
title="快速设置多语言"
@click="openI18nModal"
>
<Languages :size="16" />
</button>
</div>
</FormItem>
<FormItem label="排序号" name="b_xh">
<Input
:model-value="formData.b_xh"
type="number"
:disabled="loading"
@update:model-value="(v) => setField('b_xh', v)"
/>
</FormItem>
<FormItem label="菜单图标" name="b_icon">
<IconSelect
:model-value="formData.b_icon || ''"
:disabled="loading"
@update:model-value="(v) => setField('b_icon', v)"
/>
</FormItem>
<FormItem label="启用" name="b_canuse">
<Switch
:model-value="Number(formData.b_canuse) === 1"
:disabled="loading"
@update:model-value="(v) => setField('b_canuse', v ? 1 : 0)"
/>
</FormItem>
<div class="mm-config">
<nav class="mm-config-nav">
<div class="mm-config-nav__group">
<div class="mm-config-nav__group-title">概览</div>
<button
type="button"
class="mm-config-nav__item"
:class="{ 'is-active': activeSection === 'basic' }"
@click="activeSection = 'basic'"
>
<Info :size="14" class="mm-config-nav__icon" />
<span class="mm-config-nav__label">基本信息</span>
</button>
</div>
<div v-if="!isDirectory" class="mm-config-nav__group">
<div class="mm-config-nav__group-title">业务能力</div>
<button
type="button"
class="mm-config-nav__item"
:class="{ 'is-active': activeSection === 'powers' }"
@click="activeSection = 'powers'"
>
<Shield :size="14" class="mm-config-nav__icon" />
<span class="mm-config-nav__label">权限配置</span>
<span v-if="powersCount" class="mm-config-nav__badge">{{ powersCount }} 个权限</span>
</button>
</div>
<div class="mm-config-nav__group">
<div class="mm-config-nav__group-title">高级设置</div>
<button
type="button"
class="mm-config-nav__item"
:class="{ 'is-active': activeSection === 'i18n' }"
@click="activeSection = 'i18n'"
>
<Globe :size="14" class="mm-config-nav__icon" />
<span class="mm-config-nav__label">多语言</span>
</button>
</div>
</nav>
<FormItem v-if="isPage || isExternal" label="页面路由" name="b_route" required>
<Input
:model-value="formData.b_route"
:disabled="loading"
placeholder="固定页面路由或外链地址"
@update:model-value="(v) => setField('b_route', v)"
/>
</FormItem>
<div class="mm-config-content">
<div v-show="activeSection === 'basic'" class="mm-config-pane">
<Form ref="formRef" :model="formData" :rules="formRules" layout="vertical" :columns="2" max-width="720px">
<FormItem label="菜单编码" name="b_code" required>
<Input :model-value="formData.b_code" :disabled="loading" @update:model-value="onCodeInput" />
</FormItem>
<FormItem label="菜单名称" name="b_name" required>
<Input
:model-value="formData.b_name"
:disabled="loading"
@update:model-value="(v) => setField('b_name', v)"
/>
</FormItem>
<FormItem label="菜单类型" name="b_menu_type" required>
<Select
:model-value="formData.b_menu_type"
:options="menuTypeOptions"
:disabled="loading"
@update:model-value="(v) => setField('b_menu_type', v)"
/>
</FormItem>
<FormItem label="上级菜单" name="b_parent_id">
<Input :model-value="parentName" readonly />
</FormItem>
<FormItem label="多语言键" name="b_i18n">
<div class="mm-basic__i18n">
<Input :model-value="formData.b_i18n" readonly />
<button
type="button"
class="mm-basic__i18n-btn"
title="快速设置多语言"
@click="openI18nModal"
>
<Languages :size="16" />
</button>
</div>
</FormItem>
<FormItem label="排序号" name="b_xh">
<Input
:model-value="formData.b_xh"
type="number"
:disabled="loading"
@update:model-value="(v) => setField('b_xh', v)"
/>
</FormItem>
<FormItem label="菜单图标" name="b_icon">
<IconSelect
:model-value="formData.b_icon || ''"
:disabled="loading"
@update:model-value="(v) => setField('b_icon', v)"
/>
</FormItem>
<FormItem label="启用" name="b_canuse">
<Switch
:model-value="Number(formData.b_canuse) === 1"
:disabled="loading"
@update:model-value="(v) => setField('b_canuse', v ? 1 : 0)"
/>
</FormItem>
<FormItem v-if="isData" label="关联模块" name="b_module_id" required>
<Select
:model-value="String(formData.b_module_id || '')"
:options="moduleOptions"
:disabled="loading"
placeholder="选择数据/查询模块"
clearable
@update:model-value="(v) => setField('b_module_id', v)"
/>
</FormItem>
<FormItem v-if="isPage || isExternal" label="页面路由" name="b_route" required>
<Input
:model-value="formData.b_route"
:disabled="loading"
placeholder="固定页面路由或外链地址"
@update:model-value="(v) => setField('b_route', v)"
/>
</FormItem>
<FormItem v-if="isPage || isExternal || isData" label="访问权限编码" name="b_power_code">
<Input
:model-value="formData.b_power_code"
:disabled="loading"
placeholder="对应 s_power.b_code"
@update:model-value="(v) => setField('b_power_code', v)"
/>
</FormItem>
</Form>
</TabPane>
<FormItem v-if="isData" label="关联模块" name="b_module_id" required>
<Select
:model-value="String(formData.b_module_id || '')"
:options="moduleOptions"
:disabled="loading"
placeholder="选择数据/查询模块"
clearable
@update:model-value="(v) => setField('b_module_id', v)"
/>
</FormItem>
<TabPane tab-key="powers">
<template #label><Shield :size="14" /> 权限配置</template>
<ModulePowersPanel
:module-id="String(formData.b_id || '')"
:module-code="formData.b_code || ''"
:powers="powers"
:loading="loading"
power-type="menu"
:resource="formData.b_code || ''"
i18n-prefix="menu"
@update="(v) => emit('updatePowers', v)"
@i18n-saved="(key) => emit('i18nSaved', key)"
/>
</TabPane>
<FormItem v-if="isPage || isExternal || isData" label="访问权限编码" name="b_power_code">
<Input
:model-value="formData.b_power_code"
:disabled="loading"
placeholder="对应 s_power.b_code"
@update:model-value="(v) => setField('b_power_code', v)"
/>
</FormItem>
</Form>
</div>
<TabPane tab-key="i18n">
<template #label><Globe :size="14" /> 多语言</template>
<ModuleI18nPanel
:module-id="String(formData.b_id || '')"
:i18n-items="i18nItems"
:loading="loading"
ref="i18nPanelRef"
/>
</TabPane>
</Tabs>
<div v-if="!isDirectory" v-show="activeSection === 'powers'" class="mm-config-pane">
<ModulePowersPanel
:module-id="String(formData.b_id || '')"
:module-code="formData.b_code || ''"
:powers="powers"
:loading="loading"
power-type="menu"
:resource="formData.b_code || ''"
i18n-prefix="menu"
@update="(v) => emit('updatePowers', v)"
@i18n-saved="(key) => emit('i18nSaved', key)"
/>
</div>
<div v-show="activeSection === 'i18n'" class="mm-config-pane">
<ModuleI18nPanel
:module-id="String(formData.b_id || '')"
:i18n-items="i18nItems"
:loading="loading"
ref="i18nPanelRef"
/>
</div>
</div>
</div>
<I18nQuickSetModal ref="i18nModalRef" @saved="onI18nSaved" />
</div>
@@ -286,15 +336,94 @@ const i18nItems = computed(() => {
flex: 1;
}
.mm-menu-tabs {
/* ---- 分组配置导航 + 内容(与模块配置 mm-config 系列一致) ---- */
.mm-config {
display: flex;
flex: 1;
min-height: 0;
}
:deep(.tabs-content) {
flex: 1;
min-height: 0;
overflow: auto;
.mm-config-nav {
flex: none;
width: 168px;
padding: 10px 8px;
overflow-y: auto;
}
.mm-config-nav__group {
margin-bottom: 12px;
}
.mm-config-nav__group-title {
padding: 2px 8px 4px;
font-size: 11px;
font-weight: 600;
color: var(--fms-text-secondary);
letter-spacing: 0.4px;
}
.mm-config-nav__item {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 5px 8px;
border: none;
border-radius: var(--fms-control-radius);
background: transparent;
color: var(--fms-text);
font-size: 13px;
text-align: left;
cursor: pointer;
&:hover {
background: var(--fms-secondary);
}
&.is-active {
background: color-mix(in srgb, var(--fms-primary) 10%, transparent);
color: var(--fms-primary);
font-weight: 500;
}
}
.mm-config-nav__icon {
flex: none;
color: var(--fms-text-secondary);
}
.mm-config-nav__item.is-active .mm-config-nav__icon {
color: var(--fms-primary);
}
.mm-config-nav__label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mm-config-nav__badge {
flex: none;
padding: 0 5px;
border-radius: 999px;
background: var(--fms-secondary);
color: var(--fms-text-secondary);
font-size: 11px;
line-height: 16px;
}
.mm-config-content {
flex: 1;
min-width: 0;
min-height: 0;
overflow: auto;
padding: 12px 16px 16px;
}
.mm-config-pane {
min-width: 0;
}
.mm-basic__i18n {
@@ -8,6 +8,7 @@ import { computed, ref, watch } from 'vue'
import { Languages } from '@lucide/vue'
import Input from '@/components/ui/input/input.vue'
import Select from '@/components/ui/select/select.vue'
import Switch from '@/components/ui/switch/switch.vue'
import Form from '@/components/ui/form/form.vue'
import FormItem from '@/components/ui/form/form-item.vue'
import { buildModuleI18nKey, validateModuleCode } from '@/utils/i18n'
@@ -118,7 +119,8 @@ function onI18nSaved(key) {
<template>
<div v-if="formData" class="mm-basic">
<Form ref="formRef" :model="formData" :rules="formRules" layout="vertical" :columns="2" max-width="720px">
<Form ref="formRef" :model="formData" :rules="formRules" layout="vertical" :columns="3" max-width="720px">
<div class="mm-basic__section-title">标识与归属</div>
<FormItem :label="isCategory ? '分类编码' : '模块编码'" name="b_code" required>
<Input :model-value="formData.b_code" :disabled="loading" @update:model-value="onCodeInput" />
</FormItem>
@@ -140,7 +142,7 @@ function onI18nSaved(key) {
<FormItem label="上级模块" name="b_parent_id">
<Input :model-value="parentName" readonly />
</FormItem>
<FormItem label="多语言键" name="b_i18n">
<FormItem label="多语言键" name="b_i18n" :span="2">
<div class="mm-basic__i18n">
<Input :model-value="formData.b_i18n" readonly />
<button
@@ -153,16 +155,9 @@ function onI18nSaved(key) {
</button>
</div>
</FormItem>
<FormItem label="排序号" name="b_xh">
<Input
:model-value="formData.b_xh"
type="number"
:disabled="loading"
@update:model-value="(v) => setField('b_xh', v)"
/>
</FormItem>
<template v-if="isData || isQuery">
<div class="mm-basic__section-title">数据来源</div>
<FormItem label="查询视图" name="b_viewtable" required>
<Input
:model-value="formData.b_viewtable"
@@ -206,7 +201,25 @@ function onI18nSaved(key) {
</FormItem>
</template>
<FormItem label="描述" name="b_bz" :span="2">
<div class="mm-basic__section-title">行为设置</div>
<FormItem label="排序号" name="b_xh">
<Input
:model-value="formData.b_xh"
type="number"
:disabled="loading"
@update:model-value="(v) => setField('b_xh', v)"
/>
</FormItem>
<FormItem label="启用" name="b_canuse">
<Switch
:model-value="Number(formData.b_canuse) === 1"
:disabled="loading"
@update:model-value="(v) => setField('b_canuse', v ? 1 : 0)"
/>
</FormItem>
<div class="mm-basic__section-title">描述</div>
<FormItem label="描述" name="b_bz" :span="3">
<Input
:model-value="formData.b_bz"
type="textarea"
@@ -227,6 +240,20 @@ function onI18nSaved(key) {
flex-direction: column;
}
/* 表单内部分组区块标题:占满整行,纯文字小标题(无分隔线,更轻) */
.mm-basic__section-title {
grid-column: 1 / -1;
padding: 10px 0 6px;
margin-bottom: 2px;
font-size: 13px;
font-weight: 600;
color: var(--fms-text-secondary);
&:first-child {
padding-top: 0;
}
}
.mm-basic__i18n {
display: flex;
align-items: center;
File diff suppressed because it is too large. Load diff