20260811165111

This commit is contained in:
oneao committed 2026-08-11 16:51:11 +08:00
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;
}