20260811165111
This commit is contained in:
1 parent
5adbf59425
commit
ce78653207
34 files changed
+3793
-366
No files matched your search
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { useConfirm, handleConfirm } from "@/composables/useConfirm";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const { state } = useConfirm();
|
||||
|
||||
function onOpenChange(open: boolean) {
|
||||
if (!open) handleConfirm(false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="state.open" @update:open="onOpenChange">
|
||||
<DialogContent class="max-w-sm" :show-close-button="false">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ state.title }}</DialogTitle>
|
||||
<DialogDescription v-if="state.description">{{ state.description }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter class="gap-2 sm:gap-2">
|
||||
<Button variant="outline" @click="handleConfirm(false)">{{ state.cancelText }}</Button>
|
||||
<Button :variant="state.danger ? 'destructive' : 'default'" @click="handleConfirm(true)">
|
||||
{{ state.confirmText }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
// 加载遮罩层:覆盖在内容区域上方,显示加载状态。
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
loading?: boolean;
|
||||
text?: string;
|
||||
}>(),
|
||||
{
|
||||
loading: false,
|
||||
text: "加载中…",
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="loading"
|
||||
class="pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-background/70 backdrop-blur-sm"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<span
|
||||
class="h-8 w-8 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-primary"
|
||||
/>
|
||||
<span class="text-sm text-muted-foreground">{{ text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -55,6 +55,7 @@ watch(
|
||||
const virtualRoot = computed<TreeNode>(() => ({
|
||||
key: props.rootKey,
|
||||
title: props.rootTitle,
|
||||
isFmsRoot: true,
|
||||
children: props.treeData,
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { RotateCcw } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
sampleInterval?: number;
|
||||
historySize?: number;
|
||||
baseFps?: number;
|
||||
}>(),
|
||||
{
|
||||
sampleInterval: 500,
|
||||
historySize: 48,
|
||||
baseFps: 60,
|
||||
},
|
||||
);
|
||||
|
||||
const currentFps = ref(0);
|
||||
const averageFps = ref(0);
|
||||
const minimumFps = ref<number | null>(null);
|
||||
const history = ref<number[]>([]);
|
||||
const normalizedSampleInterval = computed(() => Math.max(100, props.sampleInterval));
|
||||
const normalizedHistorySize = computed(() => Math.max(1, Math.floor(props.historySize)));
|
||||
const normalizedBaseFps = computed(() => Math.max(1, props.baseFps));
|
||||
|
||||
let animationFrameId = 0;
|
||||
let sampleFrames = 0;
|
||||
let sampleStartedAt = 0;
|
||||
let totalFrames = 0;
|
||||
let totalTime = 0;
|
||||
|
||||
function resetSampleWindow() {
|
||||
sampleFrames = 0;
|
||||
sampleStartedAt = performance.now();
|
||||
}
|
||||
|
||||
function tick(now: number) {
|
||||
sampleFrames += 1;
|
||||
totalFrames += 1;
|
||||
|
||||
const elapsed = now - sampleStartedAt;
|
||||
if (elapsed >= normalizedSampleInterval.value) {
|
||||
const fps = Math.round((sampleFrames * 1000) / elapsed);
|
||||
currentFps.value = fps;
|
||||
minimumFps.value = minimumFps.value == null ? fps : Math.min(minimumFps.value, fps);
|
||||
totalTime += elapsed;
|
||||
averageFps.value = Math.round((totalFrames * 1000) / totalTime);
|
||||
history.value = [...history.value, fps].slice(-normalizedHistorySize.value);
|
||||
sampleFrames = 0;
|
||||
sampleStartedAt = now;
|
||||
}
|
||||
|
||||
animationFrameId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
currentFps.value = 0;
|
||||
averageFps.value = 0;
|
||||
minimumFps.value = null;
|
||||
history.value = [];
|
||||
totalFrames = 0;
|
||||
totalTime = 0;
|
||||
resetSampleWindow();
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (!document.hidden) resetSampleWindow();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
resetSampleWindow();
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
animationFrameId = requestAnimationFrame(tick);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
});
|
||||
|
||||
function getFpsColor(fps: number) {
|
||||
if (fps >= normalizedBaseFps.value * (5 / 6)) return "var(--chart-2)";
|
||||
if (fps >= normalizedBaseFps.value / 2) return "var(--chart-4)";
|
||||
return "var(--destructive)";
|
||||
}
|
||||
|
||||
const currentFpsColor = computed(() => getFpsColor(currentFps.value));
|
||||
|
||||
function getBarStyle(fps: number) {
|
||||
const height = Math.max(8, Math.min(100, (fps / normalizedBaseFps.value) * 100));
|
||||
return {
|
||||
height: `${height}%`,
|
||||
backgroundColor: getFpsColor(fps),
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="fps-monitor" aria-label="FPS 性能监控" v-bind="$attrs">
|
||||
<div class="fps-monitor__header">
|
||||
<span class="fps-monitor__label">FPS</span>
|
||||
<strong class="fps-monitor__current" :style="{ color: currentFpsColor }">
|
||||
{{ currentFps || "--" }}
|
||||
</strong>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
title="重置统计"
|
||||
aria-label="重置 FPS 统计"
|
||||
@click="reset"
|
||||
>
|
||||
<RotateCcw />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="fps-monitor__chart" aria-hidden="true">
|
||||
<span
|
||||
v-for="(fps, index) in history"
|
||||
:key="index"
|
||||
class="fps-monitor__bar"
|
||||
:style="getBarStyle(fps)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="fps-monitor__summary">
|
||||
<span
|
||||
>avg <b>{{ averageFps || "--" }}</b></span
|
||||
>
|
||||
<span
|
||||
>min <b>{{ minimumFps ?? "--" }}</b></span
|
||||
>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.fps-monitor {
|
||||
position: fixed;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 132px;
|
||||
padding: 8px 10px;
|
||||
color: var(--foreground);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
letter-spacing: 0;
|
||||
user-select: none;
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 2px 8px color-mix(in srgb, var(--foreground) 10%, transparent);
|
||||
}
|
||||
|
||||
.fps-monitor__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.fps-monitor__label {
|
||||
color: var(--muted-foreground);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fps-monitor__current {
|
||||
flex: 1;
|
||||
font-size: 18px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.fps-monitor__chart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 1px;
|
||||
height: 32px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fps-monitor__bar {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.fps-monitor__summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.fps-monitor__summary b {
|
||||
color: var(--foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
@@ -237,6 +237,7 @@ const displayColumns = computed(() => {
|
||||
|
||||
return cols.map((column) => ({
|
||||
...column,
|
||||
className: [column.className, column.editor && "fms-editor-cell"].filter(Boolean).join(" "),
|
||||
headerAlign: getHeaderAlign(column),
|
||||
}));
|
||||
});
|
||||
@@ -273,15 +274,12 @@ const hasBottomGap = computed(() => {
|
||||
return props.dataSource.length * rowHeight < bodyHeight - 1;
|
||||
});
|
||||
const customVars = computed(() => {
|
||||
// 线条只覆盖真实数据行;数据铺满视口时不绘制最后一行,避免与外框底线叠加。
|
||||
const lineRowCount = Math.max(props.dataSource.length - (hasBottomGap.value ? 0 : 1), 0);
|
||||
return {
|
||||
"--header-row-height": `${headerRowHeight}px`,
|
||||
"--row-height": `${rowHeight}px`,
|
||||
"--footer-row-height": "36px",
|
||||
"--th-bgc": "var(--background)",
|
||||
"--tf-bgc": "var(--background)",
|
||||
"--fms-data-line-height": `${lineRowCount * rowHeight}px`,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -374,8 +372,8 @@ function commitPendingEditing() {
|
||||
(item) => item === target.row || String(getRowKey(item)) === String(getRowKey(target.row)),
|
||||
);
|
||||
if (row) {
|
||||
activateEditing(row, target.focusCol);
|
||||
tableRef.value?.setCurrentRow?.(row, { silent: true });
|
||||
activateEditing(row, target.focusCol);
|
||||
lastEditActivateAt = performance.now();
|
||||
}
|
||||
}
|
||||
@@ -446,6 +444,7 @@ function handleAreaSelectionChange(ranges) {
|
||||
if (isRepeatNavigation) {
|
||||
pendingEditingTarget = { row, focusCol };
|
||||
clearActiveEditing();
|
||||
// 长按期间由选区高亮跟随目标;松键时再同步活动行,避免每次重复按键重渲染整表。
|
||||
} else {
|
||||
const now = performance.now();
|
||||
if (now - lastEditActivateAt >= 150) {
|
||||
@@ -527,7 +526,12 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="rootRef" class="fms-table" @keydown.capture="handleKeydownCapture">
|
||||
<div
|
||||
ref="rootRef"
|
||||
class="fms-table"
|
||||
:class="{ 'fms-table--bordered': bordered }"
|
||||
@keydown.capture="handleKeydownCapture"
|
||||
>
|
||||
<StkTable
|
||||
ref="tableRef"
|
||||
class="fms-table__inner"
|
||||
@@ -574,9 +578,11 @@ defineExpose({
|
||||
.fms-table {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.fms-table--bordered {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fms-table__inner {
|
||||
@@ -609,7 +615,6 @@ defineExpose({
|
||||
|
||||
/*
|
||||
* 外层绘制表格边框,表体只保留横向分割线。
|
||||
* STK 主表继续使用 fit-content 保持配置列宽,独立装饰层只负责补齐右侧空白区域的横线。
|
||||
*/
|
||||
.fms-table__inner.stk-table {
|
||||
th,
|
||||
@@ -629,29 +634,6 @@ defineExpose({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 不改变表格列宽,单独用装饰层把行分割线延伸到容器右侧空白区域。
|
||||
.stk-table-scroll-container {
|
||||
position: relative;
|
||||
min-width: 100%;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: calc(var(--header-row-height) - 1px);
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: calc(var(--fms-data-line-height) + 1px);
|
||||
pointer-events: none;
|
||||
content: "";
|
||||
background-image: repeating-linear-gradient(
|
||||
to bottom,
|
||||
var(--border) 0,
|
||||
var(--border) 1px,
|
||||
transparent 1px,
|
||||
transparent var(--row-height)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fms-checkbox {
|
||||
@@ -800,7 +782,7 @@ defineExpose({
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.fms-table__inner td:has(> .fms-cell-editor-root) {
|
||||
.fms-table__inner td.fms-editor-cell {
|
||||
padding-inline: 3px;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,15 @@ import { cn } from "@/lib/utils";
|
||||
import { createEditableCell } from "./createEditableCellRenderer";
|
||||
|
||||
const stop = (event) => event.stopPropagation();
|
||||
const actionButtonClass = cn(buttonVariants({ variant: "ghost", size: "xs" }), "fms-action-btn");
|
||||
const dangerActionButtonClass = cn(
|
||||
actionButtonClass,
|
||||
"text-destructive hover:bg-destructive/10 hover:text-destructive",
|
||||
);
|
||||
const moreActionButtonClass = cn(
|
||||
buttonVariants({ variant: "ghost", size: "xs" }),
|
||||
"fms-action-btn fms-action-more",
|
||||
);
|
||||
|
||||
function resolveActionState(state, row) {
|
||||
return typeof state === "function" ? Boolean(state(row)) : Boolean(state);
|
||||
@@ -30,7 +39,7 @@ function isActionVisible(action, row) {
|
||||
return action.visible === undefined || resolveActionState(action.visible, row);
|
||||
}
|
||||
|
||||
function actionButton(action, row, triggerAction, className = "fms-action-btn") {
|
||||
function actionButton(action, row, triggerAction) {
|
||||
const disabled = resolveActionState(action.disabled, row);
|
||||
const danger = action.danger || action.type === "danger" || action.type === "delete";
|
||||
const content = [];
|
||||
@@ -40,11 +49,7 @@ function actionButton(action, row, triggerAction, className = "fms-action-btn")
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
class: cn(
|
||||
buttonVariants({ variant: "ghost", size: "xs" }),
|
||||
danger && "text-destructive hover:bg-destructive/10 hover:text-destructive",
|
||||
className,
|
||||
),
|
||||
class: danger ? dangerActionButtonClass : actionButtonClass,
|
||||
title: action.title || action.label,
|
||||
disabled,
|
||||
onMousedown: stop,
|
||||
@@ -82,7 +87,10 @@ function createConfirmDialog(state, close, confirm) {
|
||||
h(
|
||||
AlertDialogAction,
|
||||
{
|
||||
class: config.okType === "danger" ? buttonVariants({ variant: "destructive" }) : undefined,
|
||||
class:
|
||||
config.okType === "danger"
|
||||
? buttonVariants({ variant: "destructive" })
|
||||
: undefined,
|
||||
onClick: () => {
|
||||
const pending = state.value;
|
||||
close();
|
||||
@@ -133,11 +141,10 @@ function createActionsCell(actions) {
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
class: cn(
|
||||
buttonVariants({ variant: "ghost", size: "xs" }),
|
||||
"fms-action-btn fms-action-more",
|
||||
class: [
|
||||
moreActionButtonClass,
|
||||
openRow.value === row && "bg-accent text-accent-foreground",
|
||||
),
|
||||
],
|
||||
title: moreAction.title || "更多操作",
|
||||
"aria-label": moreAction.title || "更多操作",
|
||||
onMousedown: stop,
|
||||
@@ -212,16 +219,6 @@ export function resolveCustomCell(col, editingContext) {
|
||||
if (customCell) return { ...col, customCell };
|
||||
}
|
||||
|
||||
// 纯展示列:保持文本可选中,同时通过表格选区激活行编辑。
|
||||
if (col.dataIndex) {
|
||||
return {
|
||||
...col,
|
||||
customCell: ({ row }) => {
|
||||
const value = row[col.dataIndex];
|
||||
const content = value ?? "";
|
||||
return h("span", { class: "fms-cell-display", title: String(content) }, content);
|
||||
},
|
||||
};
|
||||
}
|
||||
// 纯展示列使用 StkTable 原生渲染,避免虚拟滚动时重复创建函数组件。
|
||||
return col;
|
||||
}
|
||||
Reference in new issue
Block a user