mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-15 04:00:09 +00:00
💄 style: Add Cache, Metadata, FeatureFlag Viewer to DevPanel (#5764)
* ✨ feat: Add Chache DevTool * 💄 style: Update Dev Panel style * ✨ feat: Add seo debug * ✨ feat: Add Feature Flag * 💄 style: Update DevTool * 💄 style: Update style * 💄 style: Update style
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { memo } from 'react';
|
||||
|
||||
import Header from '../../features/Header';
|
||||
import Table from '../../features/Table';
|
||||
import { useCachePanelContext } from '../cacheProvider';
|
||||
|
||||
const DataTable = memo(() => {
|
||||
const { entries, isLoading, refreshData } = useCachePanelContext();
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
actions={[
|
||||
{
|
||||
icon: RefreshCw,
|
||||
onClick: () => refreshData(),
|
||||
title: 'Refresh',
|
||||
},
|
||||
]}
|
||||
title="Cache Entries"
|
||||
/>
|
||||
<Table
|
||||
columns={['url', 'headers.content-type', 'body', 'kind', 'tags', 'revalidate', 'timestamp']}
|
||||
dataSource={entries}
|
||||
loading={isLoading}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default DataTable;
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import {
|
||||
PropsWithChildren,
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
useTransition,
|
||||
} from 'react';
|
||||
|
||||
import { getCacheFiles } from './getCacheEntries';
|
||||
import type { NextCacheFileData } from './schema';
|
||||
|
||||
interface CachePanelContextProps {
|
||||
entries: NextCacheFileData[];
|
||||
isLoading: boolean;
|
||||
refreshData: () => void;
|
||||
setEntries: (value: NextCacheFileData[]) => void;
|
||||
}
|
||||
|
||||
const CachePanelContext = createContext<CachePanelContextProps>({
|
||||
entries: [],
|
||||
isLoading: false,
|
||||
refreshData: () => {},
|
||||
setEntries: () => {},
|
||||
});
|
||||
|
||||
export const useCachePanelContext = () => useContext(CachePanelContext);
|
||||
|
||||
export const CachePanelContextProvider = (
|
||||
props: PropsWithChildren<{
|
||||
entries: NextCacheFileData[];
|
||||
}>,
|
||||
) => {
|
||||
const [isLoading, startTransition] = useTransition();
|
||||
const [entries, setEntries] = useState(props.entries);
|
||||
const pathname = usePathname();
|
||||
|
||||
const refreshData = () => {
|
||||
startTransition(async () => {
|
||||
const files = await getCacheFiles();
|
||||
setEntries(files ?? []);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refreshData();
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<CachePanelContext.Provider
|
||||
value={{
|
||||
entries,
|
||||
isLoading,
|
||||
refreshData,
|
||||
setEntries,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</CachePanelContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
'use server';
|
||||
|
||||
import { existsSync, promises } from 'node:fs';
|
||||
import pMap from 'p-map';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
import { type NextCacheFileData, nextCacheFileSchema } from './schema';
|
||||
|
||||
const cachePath = '.next/cache/fetch-cache';
|
||||
|
||||
export const getCacheFiles = async (): Promise<NextCacheFileData[]> => {
|
||||
if (!existsSync(cachePath)) {
|
||||
return [];
|
||||
}
|
||||
const files = await promises.readdir(cachePath);
|
||||
let result: NextCacheFileData[] = (await pMap(files, async (file) => {
|
||||
// ignore tags-manifest file
|
||||
if (/manifest/.test(file)) return false;
|
||||
try {
|
||||
const fileContent = await promises.readFile(`${cachePath}/${file}`).catch((err) => {
|
||||
throw new Error(`Error reading file ${file}`, {
|
||||
cause: err,
|
||||
});
|
||||
});
|
||||
|
||||
const fileStats = await promises.stat(`${cachePath}/${file}`).catch((err) => {
|
||||
throw new Error(`Error reading file ${file}`, {
|
||||
cause: err,
|
||||
});
|
||||
});
|
||||
|
||||
const jsonData = JSON.parse(fileContent.toString());
|
||||
|
||||
return nextCacheFileSchema.parse({
|
||||
...jsonData,
|
||||
id: file,
|
||||
timestamp: new Date(fileStats.birthtime),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
const issues = error.issues;
|
||||
console.error(`File ${file} do not match the schema`, issues);
|
||||
}
|
||||
console.error(`Error parsing ${file}`);
|
||||
return false;
|
||||
}
|
||||
})) as NextCacheFileData[];
|
||||
|
||||
result = result.filter(Boolean) as NextCacheFileData[];
|
||||
|
||||
return result.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Empty } from 'antd';
|
||||
import { Center } from 'react-layout-kit';
|
||||
|
||||
import DataTable from './DataTable';
|
||||
import { CachePanelContextProvider } from './cacheProvider';
|
||||
import { getCacheFiles } from './getCacheEntries';
|
||||
|
||||
const CacheViewer = async () => {
|
||||
const files = await getCacheFiles();
|
||||
|
||||
if (!files || files.length === 0)
|
||||
return (
|
||||
<Center height={'80%'}>
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
</Center>
|
||||
);
|
||||
|
||||
return (
|
||||
<CachePanelContextProvider entries={files}>
|
||||
<DataTable />
|
||||
</CachePanelContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default CacheViewer;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const unstableCacheFileSchema = z.object({
|
||||
data: z.object({
|
||||
body: z.string(),
|
||||
headers: z.object({}).transform(() => null),
|
||||
status: z.number(),
|
||||
url: z.literal(''),
|
||||
}),
|
||||
kind: z.union([z.literal('FETCH'), z.unknown()]),
|
||||
revalidate: z.number().optional(),
|
||||
tags: z.array(z.string()).optional().default([]),
|
||||
});
|
||||
|
||||
const fetchCacheFileSchema = z.object({
|
||||
data: z.object({
|
||||
body: z.string(),
|
||||
headers: z.record(z.string(), z.string()),
|
||||
status: z.number(),
|
||||
url: z.string().url(),
|
||||
}),
|
||||
id: z.string(),
|
||||
kind: z.union([z.literal('FETCH'), z.unknown()]),
|
||||
revalidate: z.number().optional(),
|
||||
tags: z.array(z.string()).optional().default([]),
|
||||
});
|
||||
|
||||
const atou = (str: string, type: string) => {
|
||||
if (type.startsWith('image/')) return `data:${type};base64,${str}`;
|
||||
return Buffer.from(str, 'base64').toString();
|
||||
};
|
||||
export const nextCacheFileSchema = z
|
||||
.union([unstableCacheFileSchema, fetchCacheFileSchema])
|
||||
.transform((item) => {
|
||||
const { data, ...cacheEntry } = item;
|
||||
const body =
|
||||
data.url !== ''
|
||||
? atou(data.body, data.headers ? data.headers['content-type'] : '')
|
||||
: data.body;
|
||||
return {
|
||||
...cacheEntry,
|
||||
...data,
|
||||
body,
|
||||
timestamp: data.headers?.date ? new Date(data.headers?.date) : new Date(),
|
||||
url: data.url === '' ? 'unstable_cache' : data.url,
|
||||
};
|
||||
});
|
||||
|
||||
export type NextCacheFileData = z.infer<typeof nextCacheFileSchema>;
|
||||
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import { Form, Highlighter } from '@lobehub/ui';
|
||||
import { Switch } from 'antd';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { snakeCase } from 'lodash-es';
|
||||
import { ListRestartIcon } from 'lucide-react';
|
||||
import { memo, useMemo, useState } from 'react';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import { DEFAULT_FEATURE_FLAGS } from '@/config/featureFlags';
|
||||
|
||||
import Header from '../features/Header';
|
||||
|
||||
const useStyles = createStyles(({ css, token, prefixCls }) => ({
|
||||
container: css`
|
||||
* {
|
||||
font-family: ${token.fontFamilyCode};
|
||||
font-size: 12px;
|
||||
}
|
||||
.${prefixCls}-form-item {
|
||||
padding-block: 4px !important;
|
||||
}
|
||||
`,
|
||||
}));
|
||||
|
||||
const FeatureFlagForm = memo<{ flags: any }>(({ flags }) => {
|
||||
const { styles } = useStyles();
|
||||
const [data, setData] = useState(flags);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const output = useMemo(
|
||||
() =>
|
||||
Object.entries(data).map(([key, value]) => {
|
||||
const flag = snakeCase(key);
|
||||
// @ts-ignore
|
||||
if (DEFAULT_FEATURE_FLAGS[flag] === value) return false;
|
||||
if (value === true) return `+${flag}`;
|
||||
return `-${flag}`;
|
||||
}),
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
actions={[
|
||||
{
|
||||
icon: ListRestartIcon,
|
||||
onClick: () => {
|
||||
form.resetFields();
|
||||
setData(flags);
|
||||
},
|
||||
title: 'Reset',
|
||||
},
|
||||
]}
|
||||
title={'Feature Flag Env'}
|
||||
/>
|
||||
<Flexbox
|
||||
className={styles.container}
|
||||
height={'100%'}
|
||||
paddingInline={16}
|
||||
style={{ overflow: 'auto', position: 'relative' }}
|
||||
width={'100%'}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
initialValues={flags}
|
||||
itemMinWidth={'max(75%,240px)'}
|
||||
items={Object.keys(flags).map((key) => {
|
||||
return {
|
||||
children: <Switch size={'small'} />,
|
||||
label: snakeCase(key),
|
||||
minWidth: undefined,
|
||||
name: key,
|
||||
valuePropName: 'checked',
|
||||
};
|
||||
})}
|
||||
itemsType={'flat'}
|
||||
onValuesChange={(_, v) => setData(v)}
|
||||
variant={'pure'}
|
||||
/>
|
||||
</Flexbox>
|
||||
<Highlighter
|
||||
language={'env'}
|
||||
style={{ flex: 'none', fontSize: 12 }}
|
||||
wrap
|
||||
>{`FEATURE_FLAGS="${output.filter(Boolean).join(',')}"`}</Highlighter>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default FeatureFlagForm;
|
||||
@@ -0,0 +1,11 @@
|
||||
import { getServerFeatureFlagsValue } from '@/config/featureFlags';
|
||||
|
||||
import FeatureFlagForm from './Form';
|
||||
|
||||
const FeatureFlagViewer = () => {
|
||||
const serverFeatureFlags = getServerFeatureFlagsValue();
|
||||
|
||||
return <FeatureFlagForm flags={serverFeatureFlags} />;
|
||||
};
|
||||
|
||||
export default FeatureFlagViewer;
|
||||
@@ -1,136 +0,0 @@
|
||||
import { ActionIcon, Icon } from '@lobehub/ui';
|
||||
import { FloatButton } from 'antd';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { BugIcon, BugOff, XIcon } from 'lucide-react';
|
||||
import React, { PropsWithChildren, useEffect, useState } from 'react';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
import { Rnd } from 'react-rnd';
|
||||
|
||||
// 定义样式
|
||||
const useStyles = createStyles(({ token, css }) => {
|
||||
return {
|
||||
collapsed: css`
|
||||
pointer-events: none;
|
||||
transform: scale(0.8);
|
||||
opacity: 0;
|
||||
`,
|
||||
content: css`
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
color: ${token.colorText};
|
||||
`,
|
||||
|
||||
expanded: css`
|
||||
pointer-events: auto;
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
`,
|
||||
|
||||
header: css`
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
|
||||
padding-block: 8px;
|
||||
padding-inline: 16px;
|
||||
border-block-end: 1px solid ${token.colorBorderSecondary};
|
||||
border-start-start-radius: 12px;
|
||||
border-start-end-radius: 12px;
|
||||
|
||||
font-weight: ${token.fontWeightStrong};
|
||||
color: ${token.colorText};
|
||||
|
||||
background: ${token.colorFillAlter};
|
||||
`,
|
||||
panel: css`
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
|
||||
border-radius: 12px;
|
||||
|
||||
background: ${token.colorBgContainer};
|
||||
box-shadow: ${token.boxShadow};
|
||||
|
||||
transition: opacity ${token.motionDurationMid} ${token.motionEaseInOut};
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
const minWidth = 800;
|
||||
const minHeight = 600;
|
||||
|
||||
const CollapsibleFloatPanel = ({ children }: PropsWithChildren) => {
|
||||
const { styles } = useStyles();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [position, setPosition] = useState({ x: 100, y: 100 });
|
||||
const [size, setSize] = useState({ height: minHeight, width: minWidth });
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const localStoragePosition = localStorage.getItem('debug-panel-position');
|
||||
if (localStoragePosition && JSON.parse(localStoragePosition)) {
|
||||
setPosition(JSON.parse(localStoragePosition));
|
||||
}
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
|
||||
try {
|
||||
const localStorageSize = localStorage.getItem('debug-panel-size');
|
||||
if (localStorageSize && JSON.parse(localStorageSize)) {
|
||||
setSize(JSON.parse(localStorageSize));
|
||||
}
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FloatButton
|
||||
icon={<Icon icon={isExpanded ? BugOff : BugIcon} />}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
style={{ bottom: 24, right: 24 }}
|
||||
/>
|
||||
{isExpanded && (
|
||||
<Rnd
|
||||
bounds="window"
|
||||
className={`${styles.panel} ${isExpanded ? styles.expanded : styles.collapsed}`}
|
||||
dragHandleClassName="panel-drag-handle"
|
||||
minHeight={minHeight}
|
||||
minWidth={minWidth}
|
||||
onDragStop={(e, d) => {
|
||||
setPosition({ x: d.x, y: d.y });
|
||||
}}
|
||||
onResizeStop={(e, direction, ref, delta, position) => {
|
||||
setSize({
|
||||
height: Number(ref.style.height),
|
||||
width: Number(ref.style.width),
|
||||
});
|
||||
setPosition(position);
|
||||
}}
|
||||
position={position}
|
||||
size={size}
|
||||
>
|
||||
<Flexbox height={'100%'}>
|
||||
<Flexbox
|
||||
align={'center'}
|
||||
className={`panel-drag-handle ${styles.header}`}
|
||||
horizontal
|
||||
justify={'space-between'}
|
||||
>
|
||||
开发者面板
|
||||
<ActionIcon icon={XIcon} onClick={() => setIsExpanded(false)} />
|
||||
</Flexbox>
|
||||
<Flexbox className={styles.content}>{children}</Flexbox>
|
||||
</Flexbox>
|
||||
</Rnd>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollapsibleFloatPanel;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Highlighter } from '@lobehub/ui';
|
||||
import { Empty } from 'antd';
|
||||
import { memo } from 'react';
|
||||
import { Center } from 'react-layout-kit';
|
||||
|
||||
import { useLd } from './useHead';
|
||||
|
||||
const Ld = memo(() => {
|
||||
const ld = useLd();
|
||||
|
||||
if (!ld)
|
||||
return (
|
||||
<Center height={'80%'}>
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
</Center>
|
||||
);
|
||||
|
||||
return (
|
||||
<Highlighter language="json" type={'pure'}>
|
||||
{JSON.stringify(JSON.parse(ld), null, 2)}
|
||||
</Highlighter>
|
||||
);
|
||||
});
|
||||
|
||||
export default Ld;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Form } from '@lobehub/ui';
|
||||
import { Input } from 'antd';
|
||||
import { memo } from 'react';
|
||||
|
||||
import { useHead, useTitle } from './useHead';
|
||||
|
||||
const MetaData = memo(() => {
|
||||
const title = useTitle();
|
||||
const description = useHead('name', 'description');
|
||||
|
||||
return (
|
||||
<Form
|
||||
itemMinWidth={'max(75%,240px)'}
|
||||
items={[
|
||||
{
|
||||
children: <Input value={title} variant={'filled'} />,
|
||||
label: `Title (${title.length})`,
|
||||
},
|
||||
{
|
||||
children: <Input.TextArea rows={2} value={description} variant={'filled'} />,
|
||||
label: `Description (${description.length})`,
|
||||
},
|
||||
]}
|
||||
itemsType={'flat'}
|
||||
variant={'pure'}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default MetaData;
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Form } from '@lobehub/ui';
|
||||
import { Input } from 'antd';
|
||||
import Image from 'next/image';
|
||||
import { memo } from 'react';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import { useHead } from './useHead';
|
||||
|
||||
const MetaData = memo(() => {
|
||||
const ogTitle = useHead('property', 'og:title');
|
||||
const ogDescription = useHead('property', 'og:description');
|
||||
const ogImage = useHead('property', 'og:image');
|
||||
|
||||
return (
|
||||
<Form
|
||||
itemMinWidth={'max(75%,240px)'}
|
||||
items={[
|
||||
{
|
||||
children: <Input value={ogTitle} variant={'filled'} />,
|
||||
label: `OG Title (${ogTitle.length})`,
|
||||
},
|
||||
{
|
||||
children: <Input.TextArea rows={2} value={ogDescription} variant={'filled'} />,
|
||||
label: `OG Description (${ogDescription.length})`,
|
||||
},
|
||||
{
|
||||
children: (
|
||||
<Flexbox
|
||||
height={186}
|
||||
style={{
|
||||
background: 'rgba(0, 0, 0, .5)',
|
||||
borderRadius: 14,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}
|
||||
width={358}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(0, 0, 0, .5)',
|
||||
borderRadius: 4,
|
||||
bottom: 10,
|
||||
left: 10,
|
||||
lineHeight: 1.3,
|
||||
padding: '2px 6px',
|
||||
position: 'absolute',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
lobehub.com
|
||||
</div>
|
||||
<Image
|
||||
alt={'og'}
|
||||
fill
|
||||
src={ogImage}
|
||||
style={{ objectFit: 'cover' }}
|
||||
unoptimized={true}
|
||||
/>
|
||||
</Flexbox>
|
||||
),
|
||||
label: 'Og Image',
|
||||
minWidth: undefined,
|
||||
},
|
||||
{
|
||||
children: <Input value={ogImage} variant={'filled'} />,
|
||||
label: 'Og Image Url',
|
||||
},
|
||||
]}
|
||||
itemsType={'flat'}
|
||||
variant={'pure'}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default MetaData;
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { TabsNav } from '@lobehub/ui';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { memo, useState } from 'react';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import Header from '../features/Header';
|
||||
import Ld from './Ld';
|
||||
import MetaData from './MetaData';
|
||||
import Og from './Og';
|
||||
|
||||
const useStyles = createStyles(({ css, prefixCls }) => ({
|
||||
container: css`
|
||||
* {
|
||||
font-size: 12px;
|
||||
}
|
||||
.${prefixCls}-form-item {
|
||||
padding-block: 8px;
|
||||
}
|
||||
`,
|
||||
}));
|
||||
|
||||
enum Tab {
|
||||
Ld = 'ld',
|
||||
Meta = 'meta',
|
||||
Og = 'og',
|
||||
}
|
||||
|
||||
const MetadataViewer = memo(() => {
|
||||
const { styles } = useStyles();
|
||||
const [active, setActive] = useState<Tab>(Tab.Og);
|
||||
return (
|
||||
<Flexbox
|
||||
className={styles.container}
|
||||
height={'100%'}
|
||||
style={{ overflow: 'hidden', position: 'relative' }}
|
||||
width={'100%'}
|
||||
>
|
||||
<Header
|
||||
style={{ paddingInlineStart: 0 }}
|
||||
title={
|
||||
<TabsNav
|
||||
activeKey={active}
|
||||
items={[
|
||||
{
|
||||
key: Tab.Og,
|
||||
label: 'OG',
|
||||
},
|
||||
{
|
||||
key: Tab.Meta,
|
||||
label: 'MetaData',
|
||||
},
|
||||
{
|
||||
key: Tab.Ld,
|
||||
label: 'StructuredData',
|
||||
},
|
||||
]}
|
||||
onChange={(v) => setActive(v as Tab)}
|
||||
style={{ margin: 16 }}
|
||||
variant={'compact'}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Flexbox
|
||||
flex={1}
|
||||
height={'100%'}
|
||||
paddingInline={16}
|
||||
style={{ overflow: 'auto', paddingBottom: 16, position: 'relative' }}
|
||||
width={'100%'}
|
||||
>
|
||||
{active === Tab.Og && <Og />}
|
||||
{active === Tab.Meta && <MetaData />}
|
||||
{active === Tab.Ld && <Ld />}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
export default MetadataViewer;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { isOnServerSide } from '@/utils/env';
|
||||
|
||||
export const useHead = (prop: string, name: string) => {
|
||||
if (isOnServerSide) return '';
|
||||
return document.querySelector(`meta[${prop}='${name}']`)?.getAttribute('content') || '';
|
||||
};
|
||||
|
||||
export const useTitle = () => {
|
||||
if (isOnServerSide) return '';
|
||||
return document.querySelector(`title`)?.innerHTML || '';
|
||||
};
|
||||
|
||||
export const useLd = () => {
|
||||
if (isOnServerSide) return '';
|
||||
return document.querySelector(`script[type='application/ld+json']`)?.innerHTML || '';
|
||||
};
|
||||
@@ -1,34 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
interface TableCellProps {
|
||||
column: string;
|
||||
dataItem: any;
|
||||
rowIndex: number;
|
||||
}
|
||||
|
||||
const TableCell = ({ dataItem, column, rowIndex }: TableCellProps) => {
|
||||
const data = dataItem[column];
|
||||
const content = useMemo(() => {
|
||||
switch (typeof data) {
|
||||
case 'object': {
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
|
||||
case 'boolean': {
|
||||
return data ? 'True' : 'False';
|
||||
}
|
||||
|
||||
default: {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<td key={column} onDoubleClick={() => console.log('Edit cell:', rowIndex, column)}>
|
||||
{content}
|
||||
</td>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableCell;
|
||||
@@ -1,42 +1,19 @@
|
||||
import { ActionIcon, Icon } from '@lobehub/ui';
|
||||
import { Button } from 'antd';
|
||||
import { Empty } from 'antd';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { Download, Filter, RefreshCw } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { Center, Flexbox } from 'react-layout-kit';
|
||||
import { mutate } from 'swr';
|
||||
|
||||
import { FETCH_TABLE_DATA_KEY } from '../usePgTable';
|
||||
import Table from './Table';
|
||||
import Header from '../../features/Header';
|
||||
import Table from '../../features/Table';
|
||||
import { FETCH_TABLE_DATA_KEY, usePgTable, useTableColumns } from '../usePgTable';
|
||||
|
||||
const useStyles = createStyles(({ token, css }) => ({
|
||||
dataPanel: css`
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
|
||||
height: 100%;
|
||||
|
||||
background: ${token.colorBgContainer};
|
||||
`,
|
||||
toolbar: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
padding-block: 12px;
|
||||
padding-inline: 16px;
|
||||
border-block-end: 1px solid ${token.colorBorderSecondary};
|
||||
`,
|
||||
toolbarButtons: css`
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
`,
|
||||
toolbarTitle: css`
|
||||
font-size: ${token.fontSizeLG}px;
|
||||
font-weight: ${token.fontWeightStrong};
|
||||
color: ${token.colorText};
|
||||
`,
|
||||
}));
|
||||
|
||||
interface DataTableProps {
|
||||
@@ -46,29 +23,42 @@ interface DataTableProps {
|
||||
const DataTable = ({ tableName }: DataTableProps) => {
|
||||
const { styles } = useStyles();
|
||||
|
||||
return (
|
||||
<div className={styles.dataPanel}>
|
||||
{/* Toolbar */}
|
||||
<div className={styles.toolbar}>
|
||||
<div className={styles.toolbarTitle}>{tableName || 'Select a table'}</div>
|
||||
<div className={styles.toolbarButtons}>
|
||||
<Button color={'default'} icon={<Icon icon={Filter} />} variant={'filled'}>
|
||||
Filter
|
||||
</Button>
|
||||
<ActionIcon icon={Download} title={'Export'} />
|
||||
<ActionIcon
|
||||
icon={RefreshCw}
|
||||
onClick={async () => {
|
||||
await mutate(FETCH_TABLE_DATA_KEY(tableName));
|
||||
}}
|
||||
title={'Refresh'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
const tableColumns = useTableColumns(tableName);
|
||||
const tableData = usePgTable(tableName);
|
||||
const columns = tableColumns.data?.map((t) => t.name) || [];
|
||||
const isLoading = tableColumns.isLoading || tableData.isLoading;
|
||||
const dataSource = tableData.data?.data || [];
|
||||
|
||||
{/* Table */}
|
||||
<Table tableName={tableName} />
|
||||
</div>
|
||||
return (
|
||||
<Flexbox className={styles.dataPanel} flex={1} height={'100%'}>
|
||||
<Header
|
||||
actions={[
|
||||
{
|
||||
icon: Filter,
|
||||
title: 'Filter',
|
||||
},
|
||||
{
|
||||
icon: Download,
|
||||
title: 'Export',
|
||||
},
|
||||
{
|
||||
icon: RefreshCw,
|
||||
onClick: async () => {
|
||||
await mutate(FETCH_TABLE_DATA_KEY(tableName));
|
||||
},
|
||||
title: 'Refresh',
|
||||
},
|
||||
]}
|
||||
title={tableName || 'Select a table'}
|
||||
/>
|
||||
{tableName ? (
|
||||
<Table columns={columns} dataSource={dataSource} loading={isLoading} />
|
||||
) : (
|
||||
<Center height={'80%'}>
|
||||
<Empty description={'Select a table to view data'} image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
</Center>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+6
-4
@@ -3,7 +3,7 @@ import { createStyles } from 'antd-style';
|
||||
import React from 'react';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import { useTableColumns } from './usePgTable';
|
||||
import { useTableColumns } from '../usePgTable';
|
||||
|
||||
const useStyles = createStyles(({ token, css }) => ({
|
||||
container: css`
|
||||
@@ -26,7 +26,7 @@ interface TableColumnsProps {
|
||||
tableName: string;
|
||||
}
|
||||
|
||||
const TableColumns = ({ tableName }: TableColumnsProps) => {
|
||||
const Columns = ({ tableName }: TableColumnsProps) => {
|
||||
const { styles } = useStyles();
|
||||
|
||||
const { data, isLoading } = useTableColumns(tableName);
|
||||
@@ -34,7 +34,9 @@ const TableColumns = ({ tableName }: TableColumnsProps) => {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{isLoading ? (
|
||||
<div>Loading...</div>
|
||||
` <Center width={'100%'}>
|
||||
<Icon icon={Loader2Icon} spin />
|
||||
</Center>`
|
||||
) : (
|
||||
<Flexbox>
|
||||
{data?.map((column) => (
|
||||
@@ -64,4 +66,4 @@ const TableColumns = ({ tableName }: TableColumnsProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default TableColumns;
|
||||
export default Columns;
|
||||
+49
-55
@@ -1,11 +1,11 @@
|
||||
import { Icon } from '@lobehub/ui';
|
||||
import { DraggablePanel, DraggablePanelBody, Icon } from '@lobehub/ui';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { ChevronDown, ChevronRight, Database, Table as TableIcon } from 'lucide-react';
|
||||
import { ChevronDown, ChevronRight, Database, Loader2Icon, Table as TableIcon } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
import { Center, Flexbox } from 'react-layout-kit';
|
||||
|
||||
import TableColumns from './TableColumns';
|
||||
import { useFetchTables } from './usePgTable';
|
||||
import { useFetchTables } from '../usePgTable';
|
||||
import Columns from './Columns';
|
||||
|
||||
const useStyles = createStyles(({ token, css }) => ({
|
||||
button: css`
|
||||
@@ -51,24 +51,7 @@ const useStyles = createStyles(({ token, css }) => ({
|
||||
color: ${token.colorTextTertiary};
|
||||
`,
|
||||
schemaHeader: css`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
padding-block: 12px;
|
||||
padding-inline: 16px;
|
||||
|
||||
font-weight: ${token.fontWeightStrong};
|
||||
color: ${token.colorText};
|
||||
`,
|
||||
schemaPanel: css`
|
||||
overflow: scroll;
|
||||
|
||||
width: 280px;
|
||||
height: 100%;
|
||||
border-inline-end: 1px solid ${token.colorBorderSecondary};
|
||||
|
||||
background: ${token.colorBgContainer};
|
||||
`,
|
||||
selected: css`
|
||||
background: ${token.colorFillSecondary};
|
||||
@@ -146,41 +129,52 @@ const SchemaPanel = ({ onTableSelect, selectedTable }: SchemaPanelProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.schemaPanel}>
|
||||
<div className={styles.schemaHeader}>
|
||||
<Database size={16} />
|
||||
<Flexbox align={'center'} horizontal justify={'space-between'}>
|
||||
<span>Tables {data?.length}</span>
|
||||
<span className={styles.schema}>public</span>
|
||||
<DraggablePanel placement={'left'}>
|
||||
<Flexbox height={'100%'} style={{ overflow: 'hidden', position: 'relative' }}>
|
||||
<Flexbox
|
||||
align={'center'}
|
||||
className={styles.schemaHeader}
|
||||
gap={8}
|
||||
horizontal
|
||||
paddingBlock={12}
|
||||
paddingInline={16}
|
||||
>
|
||||
<Database size={16} />
|
||||
<Flexbox align={'center'} horizontal justify={'space-between'}>
|
||||
<span>Tables {data?.length}</span>
|
||||
<span className={styles.schema}>public</span>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div>Loading...</div>
|
||||
) : (
|
||||
<Flexbox>
|
||||
{data?.map((table) => (
|
||||
<div key={table.name}>
|
||||
<Flexbox
|
||||
className={cx(styles.tableItem, selectedTable === table.name && styles.selected)}
|
||||
horizontal
|
||||
onClick={() => {
|
||||
toggleTable(table.name);
|
||||
onTableSelect(table.name);
|
||||
}}
|
||||
>
|
||||
<Icon icon={expandedTables.has(table.name) ? ChevronDown : ChevronRight} />
|
||||
<TableIcon size={16} />
|
||||
<Flexbox align={'center'} horizontal justify={'space-between'}>
|
||||
<span>{table.name}</span>
|
||||
<span className={styles.count}>{table.count}</span>
|
||||
<DraggablePanelBody style={{ padding: 0 }}>
|
||||
{isLoading ? (
|
||||
<Center height={'100%'} width={'100%'}>
|
||||
<Icon icon={Loader2Icon} spin />
|
||||
</Center>
|
||||
) : (
|
||||
data?.map((table) => (
|
||||
<div key={table.name}>
|
||||
<Flexbox
|
||||
className={cx(styles.tableItem, selectedTable === table.name && styles.selected)}
|
||||
horizontal
|
||||
onClick={() => {
|
||||
toggleTable(table.name);
|
||||
onTableSelect(table.name);
|
||||
}}
|
||||
>
|
||||
<Icon icon={expandedTables.has(table.name) ? ChevronDown : ChevronRight} />
|
||||
<TableIcon size={16} />
|
||||
<Flexbox align={'center'} horizontal justify={'space-between'}>
|
||||
<span>{table.name}</span>
|
||||
<span className={styles.count}>{table.count}</span>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
{expandedTables.has(table.name) && <TableColumns tableName={table.name} />}
|
||||
</div>
|
||||
))}
|
||||
</Flexbox>
|
||||
)}
|
||||
</div>
|
||||
{expandedTables.has(table.name) && <Columns tableName={table.name} />}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</DraggablePanelBody>
|
||||
</Flexbox>
|
||||
</DraggablePanel>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import DataTable from './DataTable';
|
||||
import SchemaPanel from './Schema';
|
||||
import SchemaSidebar from './SchemaSidebar';
|
||||
|
||||
// Main Database Panel Component
|
||||
const DatabasePanel = () => {
|
||||
@@ -10,7 +12,7 @@ const DatabasePanel = () => {
|
||||
|
||||
return (
|
||||
<Flexbox height={'100%'} horizontal>
|
||||
<SchemaPanel onTableSelect={setSelectedTable} selectedTable={selectedTable} />
|
||||
<SchemaSidebar onTableSelect={setSelectedTable} selectedTable={selectedTable} />
|
||||
<DataTable tableName={selectedTable} />
|
||||
</Flexbox>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
'use client';
|
||||
|
||||
import { ActionIcon, FluentEmoji, Icon, SideNav } from '@lobehub/ui';
|
||||
import { Dropdown, FloatButton } from 'antd';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { BugIcon, BugOff, XIcon } from 'lucide-react';
|
||||
import { ReactNode, memo, useEffect, useState } from 'react';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
import { Rnd } from 'react-rnd';
|
||||
|
||||
import { BRANDING_NAME } from '@/const/branding';
|
||||
|
||||
// 定义样式
|
||||
const useStyles = createStyles(({ token, css, prefixCls }) => {
|
||||
return {
|
||||
collapsed: css`
|
||||
pointer-events: none;
|
||||
transform: scale(0.8);
|
||||
opacity: 0;
|
||||
`,
|
||||
expanded: css`
|
||||
pointer-events: auto;
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
`,
|
||||
floatButton: css`
|
||||
inset-block-end: 16px;
|
||||
inset-inline-end: 16px;
|
||||
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid ${token.colorBorderSecondary};
|
||||
|
||||
font-size: 20px;
|
||||
.${prefixCls}-float-btn-body {
|
||||
background: ${token.colorBgLayout};
|
||||
|
||||
&:hover {
|
||||
width: auto;
|
||||
background: ${token.colorBgElevated};
|
||||
}
|
||||
}
|
||||
`,
|
||||
header: css`
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
|
||||
padding-block: 8px;
|
||||
padding-inline: 16px;
|
||||
border-block-end: 1px solid ${token.colorBorderSecondary};
|
||||
|
||||
color: ${token.colorText};
|
||||
|
||||
background: ${token.colorFillAlter};
|
||||
`,
|
||||
panel: css`
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
|
||||
border: 1px solid ${token.colorBorderSecondary};
|
||||
border-radius: 12px;
|
||||
|
||||
background: ${token.colorBgContainer};
|
||||
box-shadow: ${token.boxShadow};
|
||||
|
||||
transition: opacity ${token.motionDurationMid} ${token.motionEaseInOut};
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
||||
const minWidth = 800;
|
||||
const minHeight = 600;
|
||||
|
||||
interface CollapsibleFloatPanelProps {
|
||||
items: { children: ReactNode; icon: ReactNode; key: string }[];
|
||||
}
|
||||
|
||||
const CollapsibleFloatPanel = memo<CollapsibleFloatPanelProps>(({ items }) => {
|
||||
const { styles, theme } = useStyles();
|
||||
const [tab, setTab] = useState<string>(items[0].key);
|
||||
const [isHide, setIsHide] = useState(false);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [position, setPosition] = useState({ x: 100, y: 100 });
|
||||
const [size, setSize] = useState({ height: minHeight, width: minWidth });
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const localStoragePosition = localStorage.getItem('debug-panel-position');
|
||||
if (localStoragePosition && JSON.parse(localStoragePosition)) {
|
||||
setPosition(JSON.parse(localStoragePosition));
|
||||
}
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
|
||||
try {
|
||||
const localStorageSize = localStorage.getItem('debug-panel-size');
|
||||
if (localStorageSize && JSON.parse(localStorageSize)) {
|
||||
setSize(JSON.parse(localStorageSize));
|
||||
}
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isHide && (
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
icon: (
|
||||
<Icon color={theme.colorTextSecondary} icon={BugOff} size={{ fontSize: 16 }} />
|
||||
),
|
||||
key: 'hide',
|
||||
label: 'Hide Toolbar',
|
||||
onClick: () => setIsHide(true),
|
||||
},
|
||||
],
|
||||
}}
|
||||
trigger={['hover']}
|
||||
>
|
||||
<FloatButton
|
||||
className={styles.floatButton}
|
||||
icon={<Icon icon={isExpanded ? BugOff : BugIcon} />}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
/>
|
||||
</Dropdown>
|
||||
)}
|
||||
{isExpanded && (
|
||||
<Rnd
|
||||
bounds="window"
|
||||
className={`${styles.panel} ${isExpanded ? styles.expanded : styles.collapsed}`}
|
||||
dragHandleClassName="panel-drag-handle"
|
||||
minHeight={minHeight}
|
||||
minWidth={minWidth}
|
||||
onDragStop={(e, d) => {
|
||||
setPosition({ x: d.x, y: d.y });
|
||||
}}
|
||||
onResizeStop={(e, direction, ref, delta, position) => {
|
||||
setSize({
|
||||
height: Number(ref.style.height),
|
||||
width: Number(ref.style.width),
|
||||
});
|
||||
setPosition(position);
|
||||
}}
|
||||
position={position}
|
||||
size={size}
|
||||
>
|
||||
<Flexbox
|
||||
height={'100%'}
|
||||
horizontal
|
||||
style={{ overflow: 'hidden', position: 'relative' }}
|
||||
width={'100%'}
|
||||
>
|
||||
<SideNav
|
||||
avatar={<FluentEmoji emoji={'🧰'} size={24} />}
|
||||
bottomActions={[]}
|
||||
style={{
|
||||
paddingBlock: 12,
|
||||
width: 48,
|
||||
}}
|
||||
topActions={items.map((item) => (
|
||||
<ActionIcon
|
||||
active={tab === item.key}
|
||||
key={item.key}
|
||||
onClick={() => setTab(item.key)}
|
||||
placement={'right'}
|
||||
title={item.key}
|
||||
>
|
||||
{item.icon}
|
||||
</ActionIcon>
|
||||
))}
|
||||
/>
|
||||
<Flexbox
|
||||
height={'100%'}
|
||||
style={{ overflow: 'hidden', position: 'relative' }}
|
||||
width={'100%'}
|
||||
>
|
||||
<Flexbox
|
||||
align={'center'}
|
||||
className={`panel-drag-handle ${styles.header}`}
|
||||
horizontal
|
||||
justify={'space-between'}
|
||||
>
|
||||
<Flexbox align={'baseline'} gap={6} horizontal>
|
||||
<b>{BRANDING_NAME} Dev Tools</b>
|
||||
<span style={{ color: theme.colorTextDescription }}>/</span>
|
||||
<span style={{ color: theme.colorTextDescription }}>{tab}</span>
|
||||
</Flexbox>
|
||||
<ActionIcon icon={XIcon} onClick={() => setIsExpanded(false)} />
|
||||
</Flexbox>
|
||||
{items.map((item) => (
|
||||
<Flexbox
|
||||
flex={1}
|
||||
height={'100%'}
|
||||
key={item.key}
|
||||
style={{
|
||||
display: tab === item.key ? 'flex' : 'none',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{item.children}
|
||||
</Flexbox>
|
||||
))}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</Rnd>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default CollapsibleFloatPanel;
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ActionIcon, type ActionIconProps } from '@lobehub/ui';
|
||||
import { createStyles } from 'antd-style';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { Flexbox, FlexboxProps } from 'react-layout-kit';
|
||||
|
||||
const useStyles = createStyles(({ token, css }) => ({
|
||||
header: css`
|
||||
border-block-end: 1px solid ${token.colorBorderSecondary};
|
||||
`,
|
||||
title: css`
|
||||
font-weight: 550;
|
||||
`,
|
||||
}));
|
||||
|
||||
interface HeaderProps extends Omit<FlexboxProps, 'title' | 'children'> {
|
||||
actions?: ActionIconProps[];
|
||||
extra?: ReactNode;
|
||||
title?: ReactNode;
|
||||
}
|
||||
|
||||
const Header = ({ title, actions = [], extra, ...rest }: HeaderProps) => {
|
||||
const { styles } = useStyles();
|
||||
|
||||
return (
|
||||
<Flexbox
|
||||
align={'center'}
|
||||
className={styles.header}
|
||||
flex={'none'}
|
||||
height={46}
|
||||
horizontal
|
||||
justify={'space-between'}
|
||||
paddingInline={16}
|
||||
{...rest}
|
||||
>
|
||||
<div className={styles.title}>{title}</div>
|
||||
<Flexbox align={'center'} gap={4} horizontal>
|
||||
{extra}
|
||||
{actions.map((action, index) => (
|
||||
<ActionIcon
|
||||
{...action}
|
||||
key={action.title || index}
|
||||
size={{ blockSize: 28, fontSize: 16 }}
|
||||
/>
|
||||
))}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Typography } from 'antd';
|
||||
import { createStyles } from 'antd-style';
|
||||
import dayjs from 'dayjs';
|
||||
import { get, isDate } from 'lodash-es';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import TooltipContent from './TooltipContent';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const useStyles = createStyles(({ token, css }) => ({
|
||||
cell: css`
|
||||
font-family: ${token.fontFamilyCode};
|
||||
font-size: ${token.fontSizeSM}px;
|
||||
`,
|
||||
tooltip: css`
|
||||
border: 1px solid ${token.colorBorder};
|
||||
|
||||
font-family: ${token.fontFamilyCode};
|
||||
font-size: ${token.fontSizeSM}px;
|
||||
color: ${token.colorText} !important;
|
||||
word-break: break-all;
|
||||
|
||||
background: ${token.colorBgElevated} !important;
|
||||
`,
|
||||
}));
|
||||
|
||||
interface TableCellProps {
|
||||
column: string;
|
||||
dataItem: any;
|
||||
rowIndex: number;
|
||||
}
|
||||
|
||||
const TableCell = ({ dataItem, column, rowIndex }: TableCellProps) => {
|
||||
const { styles } = useStyles();
|
||||
const data = get(dataItem, column);
|
||||
const content = useMemo(() => {
|
||||
if (isDate(data)) return dayjs(data).format('YYYY-MM-DD HH:mm:ss');
|
||||
|
||||
switch (typeof data) {
|
||||
case 'object': {
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
|
||||
case 'boolean': {
|
||||
return data ? 'True' : 'False';
|
||||
}
|
||||
|
||||
default: {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<td key={column} onDoubleClick={() => console.log('Edit cell:', rowIndex, column)}>
|
||||
<Text
|
||||
className={styles.cell}
|
||||
ellipsis={{
|
||||
tooltip: {
|
||||
arrow: false,
|
||||
classNames: { body: styles.tooltip },
|
||||
title: <TooltipContent>{content}</TooltipContent>,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Text>
|
||||
</td>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableCell;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Highlighter } from '@lobehub/ui';
|
||||
import Link from 'next/link';
|
||||
import { ReactNode, memo } from 'react';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
const TooltipContent = memo<{ children: ReactNode }>(({ children }) => {
|
||||
if (typeof children !== 'string') return children;
|
||||
|
||||
if (children.startsWith('data:image')) {
|
||||
return <img src={children} style={{ height: 'auto', maxWidth: '100%' }} />;
|
||||
}
|
||||
|
||||
if (children.startsWith('http'))
|
||||
return (
|
||||
<Link href={children} target={'_blank'}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
|
||||
const code = children.trim().trimEnd();
|
||||
|
||||
if ((code.startsWith('{') && code.endsWith('}')) || (code.startsWith('[') && code.endsWith(']')))
|
||||
return (
|
||||
<Highlighter
|
||||
language={'json'}
|
||||
style={{
|
||||
maxHeight: 400,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
type={'pure'}
|
||||
>
|
||||
{JSON.stringify(JSON.parse(code), null, 2)}
|
||||
</Highlighter>
|
||||
);
|
||||
|
||||
return <Flexbox>{children}</Flexbox>;
|
||||
});
|
||||
|
||||
export default TooltipContent;
|
||||
+12
-14
@@ -1,9 +1,10 @@
|
||||
import { Icon } from '@lobehub/ui';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { Loader2Icon } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { Center } from 'react-layout-kit';
|
||||
import { TableVirtuoso } from 'react-virtuoso';
|
||||
|
||||
import { usePgTable, useTableColumns } from '../usePgTable';
|
||||
import TableCell from './TableCell';
|
||||
|
||||
const useStyles = createStyles(({ token, css }) => ({
|
||||
@@ -88,24 +89,21 @@ const useStyles = createStyles(({ token, css }) => ({
|
||||
}));
|
||||
|
||||
interface TableProps {
|
||||
tableName?: string;
|
||||
columns: string[];
|
||||
dataSource: any[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const Table = ({ tableName }: TableProps) => {
|
||||
const Table = ({ columns, dataSource, loading }: TableProps) => {
|
||||
const { styles } = useStyles();
|
||||
|
||||
const tableColumns = useTableColumns(tableName);
|
||||
if (loading)
|
||||
return (
|
||||
<Center height={'100%'}>
|
||||
<Icon icon={Loader2Icon} spin />
|
||||
</Center>
|
||||
);
|
||||
|
||||
const tableData = usePgTable(tableName);
|
||||
|
||||
const columns = tableColumns.data?.map((t) => t.name) || [];
|
||||
const isLoading = tableColumns.isLoading || tableData.isLoading;
|
||||
|
||||
if (!tableName) return <Center height={'80%'}>Select a table to view data</Center>;
|
||||
|
||||
if (isLoading) return <Center height={'80%'}>Loading...</Center>;
|
||||
|
||||
const dataSource = tableData.data?.data || [];
|
||||
const header = (
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
@@ -1,12 +1,36 @@
|
||||
'use client';
|
||||
import { BookText, DatabaseIcon, FlagIcon, GlobeLockIcon } from 'lucide-react';
|
||||
|
||||
import FloatPanel from './FloatPanel';
|
||||
import CacheViewer from './CacheViewer';
|
||||
import FeatureFlagViewer from './FeatureFlagViewer';
|
||||
import MetadataViewer from './MetadataViewer';
|
||||
import PostgresViewer from './PostgresViewer';
|
||||
import FloatPanel from './features/FloatPanel';
|
||||
|
||||
const DevPanel = () => (
|
||||
<FloatPanel>
|
||||
<PostgresViewer />
|
||||
</FloatPanel>
|
||||
<FloatPanel
|
||||
items={[
|
||||
{
|
||||
children: <PostgresViewer />,
|
||||
icon: <DatabaseIcon size={16} />,
|
||||
key: 'Postgres Viewer',
|
||||
},
|
||||
{
|
||||
children: <MetadataViewer />,
|
||||
icon: <BookText size={16} />,
|
||||
key: 'SEO Metadata',
|
||||
},
|
||||
{
|
||||
children: <CacheViewer />,
|
||||
icon: <GlobeLockIcon size={16} />,
|
||||
key: 'NextJS Caches',
|
||||
},
|
||||
{
|
||||
children: <FeatureFlagViewer />,
|
||||
icon: <FlagIcon size={16} />,
|
||||
key: 'Feature Flags',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
export default DevPanel;
|
||||
|
||||
Reference in New Issue
Block a user