feat: harden query read-only workflow

This commit is contained in:
Logan.Li committed 2026-07-07 17:20:44 +08:00
1 parent 58bbc394e9
commit 617c15dbfe
29 files changed
+1417 -395

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
# Feature Domains
`features/*` stores product-domain code. Page files should stay thin: routing, page-level state, and composition only.
Recommended structure:
- `features/<domain>/components`: domain UI components
- `features/<domain>/hooks`: domain data/state hooks
- `features/<domain>/types.ts`: domain types
- `features/<domain>/utils.ts`: domain-only helpers
Current domains:
- `datasource`: instance inventory and MySQL/PostgreSQL engine surfaces
- `redis`: Redis value formatting, Hash/ZSet viewers, Redis command helpers
- `mongo`: MongoDB database/collection/document surfaces
- `query`: self-service query workspace
- `ticket`: change ticket lifecycle
- `governance`: workflows, audit rules, approval policy
- `rbac`: users, roles, permissions, permission groups, LDAP sync
- `system`: settings, notifications, announcements, audit logs
Migration rule:
- If a page grows past roughly 700 lines, extract domain components or hooks before adding more behavior.
- If logic is reused by two domains, move it to `shared/*` instead of another feature domain.
- API clients stay under `api/<domain>`; features consume APIs but do not define transport details.
@@ -0,0 +1,14 @@
# Datasource Domain
Scope:
- MySQL / PostgreSQL / Redis / MongoDB instance inventory
- instance create/edit/import flows
- connection testing and deployment mode display
- instance grouping entry points shared with RBAC
Migration targets:
- Move instance list filters, cards, table rows, import dialogs, and create/edit forms out of `pages/InstancePage.tsx`.
- Keep engine-specific panels under `features/datasource/engines/<engine>` when behavior diverges.
- Keep transport calls in `api/datasource`.
@@ -0,0 +1,9 @@
# MySQL Feature Scope
MySQL-specific UI and behavior:
- schema/table/column browsing
- process list and kill flow
- status metrics
- SQL query context helpers
- DDL audit hints
@@ -0,0 +1,8 @@
# PostgreSQL Feature Scope
PostgreSQL-specific UI and behavior:
- schema/table/column browsing
- process/activity views
- query context helpers
- PostgreSQL audit hints and execution caveats
@@ -0,0 +1,13 @@
# Governance Domain
Scope:
- approval workflow configuration
- audit rules
- execution windows and approval SLA
- risk grading display and policy configuration
Migration targets:
- Split `WorkflowPage.tsx` into workflow list, node editor, condition editor, and default workflow panels.
- Split `AuditRulesPage.tsx` into rule filters, rule table, and rule editor.
+13
View File
@@ -0,0 +1,13 @@
# Mongo Domain
Scope:
- MongoDB database and collection browsing
- collection stats
- document query/result rendering
- Mongo shell read-only execution helpers
Migration targets:
- Move Mongo detail tabs and document table from `pages/MongoDBDetailPage.tsx` when they grow.
- Share formatter patterns with Redis only through `shared` when truly engine-neutral.
@@ -0,0 +1,119 @@
import {
CheckCircle,
ClipboardPaste,
History,
PanelRightClose,
Search,
Star,
StarOff,
X,
} from 'lucide-react';
import type { QueryHistoryItem } from '../types';
interface QueryHistoryDrawerProps {
history: QueryHistoryItem[];
filteredHistory: QueryHistoryItem[];
favFilter: boolean;
searchHistory: string;
clearConfirm: boolean;
dbTypeIcon: Record<string, string>;
onClose: () => void;
onSearchChange: (value: string) => void;
onToggleFavoriteFilter: () => void;
onClearHistory: () => void;
onToggleFavorite: (id: number) => void;
onUseSql: (sql: string) => void;
}
export function QueryHistoryDrawer({
history,
filteredHistory,
favFilter,
searchHistory,
clearConfirm,
dbTypeIcon,
onClose,
onSearchChange,
onToggleFavoriteFilter,
onClearHistory,
onToggleFavorite,
onUseSql,
}: QueryHistoryDrawerProps) {
return (
<div className="w-[300px] shrink-0 border-l border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] flex flex-col">
<div className="flex items-center justify-between px-4 py-3">
<span className="text-sm font-semibold flex items-center gap-2">
<History className="w-4 h-4 text-blue-400" />
查询历史
{history.length > 0 && <span className="text-xs dg-text-muted font-normal">({history.length})</span>}
</span>
<button onClick={onClose} className="dg-btn dg-btn-ghost dg-btn-sm p-1">
<PanelRightClose className="w-4 h-4" />
</button>
</div>
{history.length > 0 && (
<div className="px-4 pb-2 space-y-2">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 dg-text-muted" />
<input
className="w-full pl-8 pr-3 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-xs placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none"
placeholder="搜索历史..."
value={searchHistory}
onChange={function (e) { onSearchChange(e.target.value); }}
/>
</div>
<div className="flex items-center gap-2">
<button onClick={onToggleFavoriteFilter}
className={'dg-btn dg-btn-ghost dg-btn-sm ' + (favFilter ? 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30' : '')}>
<Star className="w-3 h-3" />仅收藏
</button>
<div className="flex-1" />
<button onClick={onClearHistory}
className={'dg-btn dg-btn-ghost dg-btn-sm ' + (clearConfirm ? 'text-red-400 bg-red-500/10' : '')}>
{clearConfirm ? <CheckCircle className="w-3 h-3" /> : <X className="w-3 h-3" />}
{clearConfirm ? '确认清除' : '清除'}
</button>
</div>
</div>
)}
<div className="flex-1 overflow-y-auto">
{history.length === 0 ? (
<div className="p-6 text-center text-xs dg-text-muted">暂无查询历史</div>
) : filteredHistory.length === 0 ? (
<div className="p-6 text-center text-xs dg-text-muted">没有匹配的记录</div>
) : (
<div className="space-y-1.5 px-3 py-2">
{filteredHistory.map(function (h) {
return (
<div key={h.id} className={'dg-card px-3 py-2.5 group ' + (h.favorite ? 'bg-yellow-500/5 border-yellow-500/20' : '')}>
<div className="flex items-center gap-2 mb-1">
{h.db_type && dbTypeIcon[h.db_type] && <img src={dbTypeIcon[h.db_type]} alt="" className="w-3.5 h-3.5 object-contain" />}
<span className={'text-[10px] px-1.5 py-0.5 rounded-full ' + (h.status === 'success' ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400')}>
{h.execution_time_ms}ms
</span>
<span className="text-[10px] dg-text-muted">{new Date(h.created_at).toLocaleTimeString()}</span>
<div className="flex-1" />
<button onClick={function () { onToggleFavorite(h.id); }}
className={'p-0.5 rounded transition-colors ' + (h.favorite ? 'text-yellow-400' : 'dg-text-muted opacity-0 group-hover:opacity-100 hover:text-yellow-400')}>
{h.favorite ? <Star className="w-3 h-3 fill-current" /> : <StarOff className="w-3 h-3" />}
</button>
</div>
<div className="flex items-center gap-1">
<code className="flex-1 text-[11px] font-mono text-blue-400 truncate">{h.sql.substring(0, 80)}</code>
<button onClick={function () { onUseSql(h.sql); }}
className="dg-btn dg-btn-ghost dg-btn-sm p-1 opacity-0 group-hover:opacity-100"
title="粘贴到编辑器">
<ClipboardPaste className="w-3 h-3" />
</button>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,70 @@
import { Search } from 'lucide-react';
import type { QueryTemplate } from '@/lib/queryTemplates';
interface QueryTemplatePanelProps {
categories: string[];
activeCategory: string;
categoryLabels: Record<string, string>;
search: string;
templates: QueryTemplate[];
onCategoryChange: (category: string) => void;
onSearchChange: (value: string) => void;
onApplyTemplate: (template: QueryTemplate) => void;
}
export function QueryTemplatePanel({
categories,
activeCategory,
categoryLabels,
search,
templates,
onCategoryChange,
onSearchChange,
onApplyTemplate,
}: QueryTemplatePanelProps) {
return (
<div className="shrink-0 px-5 pb-2">
<div className="dg-card p-3">
<div className="flex items-center gap-2 mb-3 flex-wrap">
{categories.map(function (cat) {
return (
<button key={cat} onClick={function () { onCategoryChange(cat); }}
className={'dg-btn dg-btn-ghost dg-btn-sm ' + (activeCategory === cat ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : '')}>
{categoryLabels[cat] || cat}
</button>
);
})}
<div className="flex-1" />
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 dg-text-muted" />
<input
value={search}
onChange={function (e) { onSearchChange(e.target.value); }}
className="pl-7 pr-3 py-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-md text-xs focus:border-blue-400 focus:outline-none w-40"
placeholder="搜索模板..."
/>
</div>
</div>
{templates.length === 0 ? (
<div className="text-center py-4 text-xs dg-text-muted">无匹配模板</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 max-h-48 overflow-y-auto">
{templates.map(function (tpl) {
return (
<button key={tpl.id} onClick={function () { onApplyTemplate(tpl); }}
className="dg-card text-left p-2.5 hover:bg-[var(--dg-bg-hover)] group"
title={tpl.description}>
<div className="flex items-center gap-2 mb-0.5">
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400 font-medium">{tpl.category}</span>
<span className="text-sm font-medium group-hover:text-blue-400 transition-colors truncate">{tpl.name}</span>
</div>
<p className="text-xs dg-text-muted line-clamp-1">{tpl.description}</p>
</button>
);
})}
</div>
)}
</div>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
export interface QueryHistoryItem {
id: number;
sql: string;
instance_id: number;
database: string;
db_type: string;
status: string;
execution_time_ms: number;
result_preview: string;
created_at: string;
favorite?: boolean;
}
+22
View File
@@ -0,0 +1,22 @@
export function exportToCSV(columns: string[], rows: Record<string, unknown>[]): string {
var header = columns.join(',');
var lines = rows.map(function (row) {
return columns.map(function (col) {
var val = String(row[col] ?? '');
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
return '"' + val.replace(/"/g, '""') + '"';
}
return val;
}).join(',');
});
return [header].concat(lines).join('\n');
}
export function downloadCSV(filename: string, content: string) {
var blob = new Blob([content], { type: 'text/csv;charset=utf-8;' });
var link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
URL.revokeObjectURL(link.href);
}
+14
View File
@@ -0,0 +1,14 @@
# RBAC Domain
Scope:
- users
- roles and permissions
- permission groups / instance groups
- LDAP import and sync flows
Migration targets:
- Split `pages/UsersPage.tsx` into user list, user forms, password reset, LDAP import, and permission assignment components.
- Keep permission constants and policy helpers here.
- Keep generic modals and tables in `shared` only if reused outside RBAC.
@@ -0,0 +1,98 @@
import { Copy, Eye } from 'lucide-react';
import type { RedisLineEditorState } from '../types';
import {
middleEllipsis,
parseRedisKeyFromCommand,
} from '../utils';
interface RedisHashQueryResultProps {
rows: Record<string, unknown>[];
rawKey?: string;
rawType?: string;
rawTtl?: string;
command: string;
onView: (state: RedisLineEditorState) => void;
}
function compactPreview(value: string): string {
try {
return JSON.stringify(JSON.parse(value));
} catch {
return value;
}
}
function endEllipsis(value: string, maxLength: number): string {
if (value.length <= maxLength) return value;
return value.slice(0, Math.max(0, maxLength - 3)) + '...';
}
export function RedisHashQueryResult({ rows, rawKey, rawType, rawTtl, command, onView }: RedisHashQueryResultProps) {
var redisKey = String(rawKey || parseRedisKeyFromCommand(command) || '');
var redisKeyLabel = redisKey ? middleEllipsis(redisKey, 20) : '-';
return (
<div>
<div className="flex items-center gap-2 border-b border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-3 py-2 text-xs">
<span className="rounded border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] px-3 py-1.5 font-semibold text-blue-400">
{String(rawType || 'hash').replace(/^./, function (c) { return c.toUpperCase(); })}
</span>
<span
className="min-w-0 max-w-[220px] flex-1 truncate rounded border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-3 py-1.5 font-mono text-[var(--dg-text-primary)]"
title={redisKey}
>
{redisKeyLabel}
</span>
<span className="rounded border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] px-3 py-1.5 font-mono text-[var(--dg-text-muted)]">
TTL {String(rawTtl ?? '-1')}
</span>
</div>
<table className="w-full table-fixed border-collapse text-sm">
<colgroup>
<col className="w-14" />
<col className="w-36" />
<col />
<col className="w-20" />
</colgroup>
<thead className="sticky top-0 z-10">
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
<th className="px-3 py-2 text-left text-xs font-semibold text-[var(--dg-text-muted)]">ID</th>
<th className="px-4 py-2 text-left text-xs font-semibold text-[var(--dg-text-muted)]">Key</th>
<th className="px-4 py-2 text-left text-xs font-semibold text-[var(--dg-text-muted)]">Value</th>
<th className="px-3 py-2 text-right text-xs font-semibold text-[var(--dg-text-muted)]">操作</th>
</tr>
</thead>
<tbody>
{rows.map(function (row, i) {
var field = String(row.field ?? '');
var fieldLabel = middleEllipsis(field, 14);
var value = String(row.value ?? '');
var preview = compactPreview(value);
var previewLabel = endEllipsis(preview, 180);
return (
<tr key={i} className="h-10 border-b border-[var(--dg-border)] transition-colors hover:bg-[var(--dg-bg-hover)]">
<td className="px-3 py-2 text-xs text-[var(--dg-text-muted)]">{i + 1}</td>
<td className="max-w-0 px-4 py-2 font-mono text-xs text-[var(--dg-text-primary)]">
<div className="truncate" title={field}>{fieldLabel}</div>
</td>
<td className="max-w-0 px-4 py-2 font-mono text-xs text-[var(--dg-text-secondary)]">
<div className="truncate" title={preview}>{previewLabel}</div>
</td>
<td className="px-3 py-2">
<div className="flex justify-end gap-1">
<button onClick={function () { navigator.clipboard.writeText(preview); }} className="inline-flex h-7 w-7 items-center justify-center rounded text-blue-400 hover:bg-blue-500/10" title="复制格式化值">
<Copy className="h-3.5 w-3.5" />
</button>
<button onClick={function () { onView({ open: true, field: field, value: value, format: 'Json' }); }} className="inline-flex h-7 w-7 items-center justify-center rounded text-blue-400 hover:bg-blue-500/10" title="查看/格式化">
<Eye className="h-3.5 w-3.5" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,75 @@
import { Copy } from 'lucide-react';
import { FormDialog } from '@/components/FormDialog';
import { STRING_FORMAT_OPTIONS, type RedisLineEditorState } from '../types';
import { formatStringByMode } from '../utils';
interface RedisLineEditorDialogProps {
state: RedisLineEditorState;
readOnlyField?: boolean;
saving?: boolean;
onChange: (state: RedisLineEditorState) => void;
onClose: () => void;
onConfirm: () => void;
onCopy?: (value: string) => void;
}
export function RedisLineEditorDialog({
state,
readOnlyField,
saving,
onChange,
onClose,
onConfirm,
onCopy,
}: RedisLineEditorDialogProps) {
return (
<FormDialog open={state.open} onClose={onClose} title="编辑字段" width="max-w-4xl"
footer={<>
<button onClick={onClose} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg text-sm">取消</button>
<button onClick={onConfirm} disabled={saving} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm disabled:opacity-50">保存</button>
</>}>
<div className="space-y-4">
<div>
<label className="text-sm text-[var(--dg-text-muted)]">Field</label>
<input
readOnly={readOnlyField}
value={state.field}
onChange={function (e) { onChange(Object.assign({}, state, { field: e.target.value })); }}
className="mt-1 h-10 w-full rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-3 font-mono text-sm"
/>
</div>
<div>
<div className="mb-2 flex items-center justify-between gap-3">
<label className="text-sm text-[var(--dg-text-muted)]">Value</label>
<div className="flex items-center gap-2">
<span className="rounded-md border border-blue-400/30 bg-blue-500/10 px-2 py-1 text-xs text-blue-400">
Size: {new TextEncoder().encode(state.value).length}B
</span>
<button onClick={function () { onCopy ? onCopy(state.value) : navigator.clipboard.writeText(state.value); }} className="inline-flex items-center gap-1.5 text-xs text-blue-400 hover:text-blue-300">
<Copy className="h-3.5 w-3.5" />Copy
</button>
<select
value={state.format}
onChange={function (e) { onChange(Object.assign({}, state, { format: e.target.value as RedisLineEditorState['format'] })); }}
className="h-8 w-44 rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-2 text-xs text-[var(--dg-text-secondary)]"
>
{STRING_FORMAT_OPTIONS.map(function (format) {
return <option key={format} value={format}>{format}</option>;
})}
</select>
</div>
</div>
<textarea
value={state.value}
onChange={function (e) { onChange(Object.assign({}, state, { value: e.target.value })); }}
spellCheck={false}
className="min-h-[160px] w-full resize-y rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] p-3 font-mono text-xs leading-relaxed outline-none focus:border-[var(--dg-blue)]"
/>
<pre className="mt-2 max-h-72 overflow-auto whitespace-pre-wrap break-all rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] p-3 font-mono text-xs leading-relaxed text-[var(--dg-text-secondary)]">
{formatStringByMode(state.value, state.format)}
</pre>
</div>
</div>
</FormDialog>
);
}
@@ -0,0 +1,160 @@
import { Code2, Copy, Eye, Pencil, Trash2 } from 'lucide-react';
import {
compactValuePreview,
normalizeZSetRows,
normalizeValueForEdit,
redisCommandForHashField,
tryFormatJsonText,
} from '../utils';
interface RedisStructuredValueProps {
type: string;
value: any;
redisKey: string;
search: string;
onCopy: (text: string, message: string) => void;
onInspect: (title: string, value: any) => void;
onEditHashLine: (field: string, value: any) => void;
onDeleteHashLine: (field: string) => void;
}
export function RedisStructuredValue({
type,
value,
redisKey,
search,
onCopy,
onInspect,
onEditHashLine,
onDeleteHashLine,
}: RedisStructuredValueProps) {
var normalizedValue = normalizeValueForEdit(type, value);
var normalizedSearch = search.trim().toLowerCase();
if (type === 'hash') {
var entries = Object.entries(normalizedValue || {}).filter(function (entry) {
return !normalizedSearch || entry[0].toLowerCase().includes(normalizedSearch) || String(entry[1]).toLowerCase().includes(normalizedSearch);
});
return (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--dg-border)] text-left text-xs text-[var(--dg-text-muted)]">
<th className="w-28 px-3 py-2 font-medium">ID (Total: {entries.length})</th>
<th className="w-[30%] px-3 py-2 font-medium">Key</th>
<th className="px-3 py-2 font-medium">Value</th>
<th className="w-36 px-3 py-2 text-right font-medium">操作</th>
</tr>
</thead>
<tbody>
{entries.map(function (entry, index) {
var field = String(entry[0]);
var preview = compactValuePreview(entry[1]);
return (
<tr key={field} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)]">
<td className="px-3 py-2 text-xs text-[var(--dg-text-muted)]">{index + 1}</td>
<td className="px-3 py-2 font-mono text-xs text-[var(--dg-text-primary)]">
<div className="max-w-[320px] truncate" title={field}>{field}</div>
</td>
<td className="px-3 py-2 font-mono text-xs text-[var(--dg-text-secondary)]">
<div className="truncate" title={preview}>{preview}</div>
</td>
<td className="px-3 py-2">
<div className="flex justify-end gap-1">
<button onClick={function () { onCopy(String(entry[1] ?? ''), '已复制格式化值'); }} className="inline-flex h-7 w-7 items-center justify-center rounded text-blue-400 hover:bg-blue-500/10" title="格式化复制">
<Copy className="h-3.5 w-3.5" />
</button>
<button onClick={function () { onEditHashLine(field, entry[1]); }} className="inline-flex h-7 w-7 items-center justify-center rounded text-blue-400 hover:bg-blue-500/10" title="编辑">
<Pencil className="h-3.5 w-3.5" />
</button>
<button onClick={function () { onDeleteHashLine(field); }} className="inline-flex h-7 w-7 items-center justify-center rounded text-red-400 hover:bg-red-500/10" title="删除字段">
<Trash2 className="h-3.5 w-3.5" />
</button>
<button onClick={function () { onCopy(redisCommandForHashField(redisKey, field, entry[1]), '已复制 HSET 命令'); }} className="inline-flex h-7 w-7 items-center justify-center rounded text-blue-400 hover:bg-blue-500/10" title="复制命令行">
<Code2 className="h-3.5 w-3.5" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
);
}
if (type === 'list' || type === 'set') {
var list = Array.isArray(normalizedValue) ? normalizedValue.filter(function (item) {
return !normalizedSearch || String(item).toLowerCase().includes(normalizedSearch);
}) : [];
return (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--dg-border)] text-left text-xs text-[var(--dg-text-muted)]">
<th className="w-20 px-3 py-2 font-medium">Index</th>
<th className="px-3 py-2 font-medium">Value</th>
</tr>
</thead>
<tbody>
{list.map(function (item, index) {
var pretty = typeof item === 'string' ? (tryFormatJsonText(item) || item) : JSON.stringify(item, null, 2);
return (
<tr key={index} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)]">
<td className="px-3 py-2 text-xs text-[var(--dg-text-muted)]">{index}</td>
<td className="px-3 py-2"><pre className="max-h-28 overflow-auto whitespace-pre-wrap break-all rounded bg-[var(--dg-bg-primary)] px-2 py-1.5 font-mono text-xs text-[var(--dg-text-secondary)]">{pretty}</pre></td>
</tr>
);
})}
</tbody>
</table>
);
}
if (type === 'zset') {
var zRows = normalizeZSetRows(normalizedValue).filter(function (row) {
return !normalizedSearch || row.member.toLowerCase().includes(normalizedSearch) || String(row.score).includes(normalizedSearch);
});
return (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--dg-border)] text-left text-xs text-[var(--dg-text-muted)]">
<th className="w-28 px-3 py-2 font-medium">ID (Total: {zRows.length})</th>
<th className="w-36 px-3 py-2 font-medium">Score</th>
<th className="px-3 py-2 font-medium">Member</th>
<th className="w-24 px-3 py-2 text-right font-medium">操作</th>
</tr>
</thead>
<tbody>
{zRows.map(function (row, index) {
var memberPreview = compactValuePreview(row.member);
return (
<tr key={index} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)]">
<td className="px-3 py-2 text-xs text-[var(--dg-text-muted)]">{index + 1}</td>
<td className="px-3 py-2 font-mono text-xs text-[var(--dg-text-secondary)]">{row.score}</td>
<td className="px-3 py-2 font-mono text-xs text-[var(--dg-text-primary)]">
<div className="truncate" title={memberPreview}>{memberPreview}</div>
</td>
<td className="px-3 py-2">
<div className="flex justify-end gap-1">
<button onClick={function () { onCopy(row.member, '已复制 Member'); }} className="inline-flex h-7 w-7 items-center justify-center rounded text-[var(--dg-text-muted)] hover:bg-[var(--dg-bg-tertiary)] hover:text-[var(--dg-text-primary)]" title="复制 Member">
<Copy className="h-3.5 w-3.5" />
</button>
<button onClick={function () { onInspect('score ' + row.score, row.member); }} className="inline-flex h-7 w-7 items-center justify-center rounded text-[var(--dg-text-muted)] hover:bg-[var(--dg-bg-tertiary)] hover:text-[var(--dg-text-primary)]" title="格式化查看">
<Eye className="h-3.5 w-3.5" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
);
}
var formatted = tryFormatJsonText(String(normalizedValue ?? ''));
return (
<pre className="min-h-[240px] whitespace-pre-wrap break-all rounded bg-[var(--dg-bg-primary)] p-4 font-mono text-xs text-[var(--dg-text-secondary)]">
{formatted || String(normalizedValue ?? '')}
</pre>
);
}
@@ -0,0 +1,65 @@
import { Copy } from 'lucide-react';
import { FormDialog } from '@/components/FormDialog';
import { STRING_FORMAT_OPTIONS, type RedisLineEditorState } from '../types';
import { formatStringByMode } from '../utils';
interface RedisValueViewerDialogProps {
state: RedisLineEditorState;
onChange: (state: RedisLineEditorState) => void;
onClose: () => void;
onCopy?: (value: string) => void;
}
export function RedisValueViewerDialog({
state,
onChange,
onClose,
onCopy,
}: RedisValueViewerDialogProps) {
return (
<FormDialog open={state.open} onClose={onClose} title="查看值" width="max-w-4xl"
footer={<button onClick={onClose} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg text-sm">关闭</button>}>
<div className="space-y-4">
<div>
<label className="text-sm text-[var(--dg-text-muted)]">Field</label>
<input
readOnly
value={state.field}
className="mt-1 h-10 w-full rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-3 font-mono text-sm text-[var(--dg-text-secondary)]"
/>
</div>
<div>
<div className="mb-2 flex items-center justify-between gap-3">
<label className="text-sm text-[var(--dg-text-muted)]">Value</label>
<div className="flex items-center gap-2">
<span className="rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] px-2 py-1 text-xs text-[var(--dg-text-muted)]">
Size: {new TextEncoder().encode(state.value).length}B
</span>
<button onClick={function () { onCopy ? onCopy(state.value) : navigator.clipboard.writeText(state.value); }} className="inline-flex items-center gap-1.5 text-xs text-blue-400 hover:text-blue-300">
<Copy className="h-3.5 w-3.5" />复制
</button>
<select
value={state.format}
onChange={function (e) { onChange(Object.assign({}, state, { format: e.target.value as RedisLineEditorState['format'] })); }}
className="h-8 w-44 rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-2 text-xs text-[var(--dg-text-secondary)]"
>
{STRING_FORMAT_OPTIONS.map(function (format) {
return <option key={format} value={format}>{format}</option>;
})}
</select>
</div>
</div>
<textarea
readOnly
value={state.value}
spellCheck={false}
className="min-h-[160px] w-full resize-y rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] p-3 font-mono text-xs leading-relaxed text-[var(--dg-text-secondary)] outline-none"
/>
<pre className="mt-2 max-h-72 overflow-auto whitespace-pre-wrap break-all rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] p-3 font-mono text-xs leading-relaxed text-[var(--dg-text-secondary)]">
{formatStringByMode(state.value, state.format)}
</pre>
</div>
</div>
</FormDialog>
);
}
+40
View File
@@ -0,0 +1,40 @@
export type StringFormat =
| 'String'
| 'Text'
| 'Hex'
| 'Json'
| 'Binary'
| 'Msgpack'
| 'PHPSerialize'
| 'Java Serialize'
| 'Pickle'
| 'Brotli'
| 'Gzip'
| 'Deflate'
| 'DeflateRaw'
| 'Protobuf';
export const STRING_FORMAT_OPTIONS: StringFormat[] = [
'String',
'Text',
'Hex',
'Json',
'Binary',
'Msgpack',
'PHPSerialize',
'Java Serialize',
'Pickle',
'Brotli',
'Gzip',
'Deflate',
'DeflateRaw',
'Protobuf',
];
export interface RedisLineEditorState {
open: boolean;
originalField?: string;
field: string;
value: string;
format: StringFormat;
}
+105
View File
@@ -0,0 +1,105 @@
import type { StringFormat } from './types';
export function ttlMsToSeconds(ttl: number | undefined): number {
if (ttl == null || ttl < 0) return -1;
return Math.max(1, Math.ceil(ttl / 1000));
}
export function formatTTL(ttl: number | undefined): string {
if (ttl == null || ttl < 0) return '永不过期';
if (ttl === 0) return '即将过期';
var seconds = Math.ceil(ttl / 1000);
if (seconds < 60) return seconds + 's';
if (seconds < 3600) return Math.floor(seconds / 60) + 'm ' + (seconds % 60) + 's';
return Math.floor(seconds / 3600) + 'h ' + Math.floor((seconds % 3600) / 60) + 'm';
}
export function normalizeZSetRows(value: any): { member: string; score: number }[] {
if (!Array.isArray(value)) return [];
return value.map(function (row: any) {
if (Array.isArray(row)) {
return { member: String(row[0] ?? ''), score: Number(row[1] ?? 0) };
}
return {
member: String(row?.member ?? row?.Member ?? ''),
score: Number(row?.score ?? row?.Score ?? 0),
};
});
}
export function normalizeValueForEdit(type: string, value: any): any {
if (type === 'zset') return normalizeZSetRows(value);
return value;
}
export function stringifyValue(type: string, value: any): string {
if (type === 'string') return typeof value === 'string' ? value : String(value ?? '');
return JSON.stringify(normalizeValueForEdit(type, value), null, 2);
}
export function parseEditorValue(type: string, text: string): any {
if (type === 'string') return text;
return JSON.parse(text || (type === 'hash' ? '{}' : '[]'));
}
export function tryFormatJsonText(text: string): string | null {
try {
return JSON.stringify(JSON.parse(text), null, 2);
} catch {
return null;
}
}
export function formatStringByMode(text: string, mode: StringFormat): string {
if (mode === 'String' || mode === 'Text') return text;
if (mode === 'Json') return tryFormatJsonText(text) || '不是合法 JSON,已显示原始内容:\n\n' + text;
var bytes = Array.from(new TextEncoder().encode(text));
if (mode === 'Hex') return bytes.map(function (byte) { return byte.toString(16).padStart(2, '0'); }).join(' ');
if (mode === 'Binary') return bytes.map(function (byte) { return byte.toString(2).padStart(8, '0'); }).join(' ');
return '当前前端暂不支持 ' + mode + ' 解码,已显示原始内容:\n\n' + text;
}
export function compactValuePreview(value: any): string {
var text = typeof value === 'string' ? value : JSON.stringify(value);
var formatted = typeof value === 'string' ? tryFormatJsonText(value) : null;
if (formatted) {
try {
text = JSON.stringify(JSON.parse(value));
} catch {
text = formatted.replace(/\s+/g, ' ');
}
}
return text || '';
}
export function middleEllipsis(text: string, maxLength: number = 36): string {
if (text.length <= maxLength) return text;
var keep = Math.max(6, Math.floor((maxLength - 3) / 2));
return text.slice(0, keep) + '...' + text.slice(text.length - keep);
}
export function redisCliQuote(value: any): string {
return JSON.stringify(String(value ?? ''));
}
export function redisCommandForHashField(key: string, field: string, value: any): string {
return 'HSET ' + redisCliQuote(key) + ' ' + redisCliQuote(field) + ' ' + redisCliQuote(value);
}
export function redisDeleteCommandForHashField(key: string, field: string): string {
return 'HDEL ' + redisCliQuote(key) + ' ' + redisCliQuote(field);
}
export function parseRedisKeyFromCommand(command: string): string {
var parts = command.match(/"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|\S+/g) || [];
if (parts.length < 2) return '';
var raw = parts[1];
if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
try {
return JSON.parse(raw.replace(/^'/, '"').replace(/'$/, '"'));
} catch {
return raw.slice(1, -1);
}
}
return raw;
}
+15
View File
@@ -0,0 +1,15 @@
# System Domain
Scope:
- system settings
- notification channels
- announcements
- audit logs
- appearance settings
- platform cleanup and retention settings
Migration targets:
- Unify settings pages under one system domain.
- Keep low-level generic controls in `shared/ui`.
+14
View File
@@ -0,0 +1,14 @@
# Ticket Domain
Scope:
- change ticket list
- ticket create and detail
- approval timeline
- execution confirmation and rollback evidence
- todo / pending work views
Migration targets:
- Split `TicketsPage.tsx` and `TicketDetailPage.tsx` into list, filters, stats, detail header, timeline, SQL diff, approval actions, and execution panels.
- Keep SQL editor reuse in shared/query components only when it is not ticket-specific.