Files
lingxi/apps/web/src/pages/system/dicts/DictDetailPanel.tsx
T
Stev_Wang 3b0a99d91f feat(web): A4 数据字典与系统配置页
背景:用户选定方案 A 主从分栏,A4 需要落地字典与配置页。\n改动:新增 /system/dicts、/system/dicts/:type、/system/settings;主从类型列表 + 三层合并 + 树/词表切换 + 条目/类型弹层;6 组配置 + 命名预览 + 阈值二次确认 + 变更记录;客户字段上限改读 effective config;路由/样式/OpenAPI 类型同步。\n影响面:管理中心新增两个菜单页;客户页上限显示改为配置读取(fallback 共享常量)。\n回滚:回滚本提交并恢复 AppLayout / routes / main 样式 import。\n验证:web typecheck/lint/build;单测 154;浏览器自检 1440/1024 无溢出。
2026-11-04 11:49:32 +08:00

462 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable max-lines-per-function, complexity -- 页面组件集中编排筛选、表单与弹层,拆散会割裂数据流;纯逻辑已抽到 *-utils.ts */
import { Button, RadioGroup, Select, Tag } from '@douyinfe/semi-ui-19';
import {
DICT_GROUPS,
type DictItem,
type DictItemListResponse,
type DictTypeDetail,
} from '@lingxi/shared';
import { ErrorState, PageEmpty } from '@lingxi/ui';
import { IconAdd, IconAlert, IconEdit, IconPause, IconPlay, IconView } from '@lingxi/ui/icons';
import type { ReactNode } from 'react';
import { useMemo } from 'react';
import {
dictItemViewStats,
dictScopeChips,
dictTypeKindLabel,
formatDictDateTime,
} from './dict-utils';
import { DictGroupIcon } from './DictGroupIcon';
import { DictItemsView } from './DictItemsView';
export interface DictScopeOption {
value: string | null;
label: string;
}
const EMPTY_ITEMS: DictItemListResponse = {
items: [],
next_cursor: null,
layers: [],
effectiveCount: 0,
overriddenCount: 0,
disabledCount: 0,
};
function groupNameOf(groupKey: DictTypeDetail['group']): string {
return DICT_GROUPS.find((group) => group.key === groupKey)?.name ?? groupKey;
}
function DetailSkeleton(): ReactNode {
return (
<div className="lx-dict-detail-skeleton" aria-hidden="true">
<div className="lx-dict-detail-skeleton__head">
<span className="lx-dict-detail-skeleton__avatar" />
<span className="lx-dict-detail-skeleton__lines">
<span />
<span />
<span />
</span>
</div>
<span className="lx-dict-detail-skeleton__bar" />
<div className="lx-dict-detail-skeleton__rows">
{Array.from({ length: 6 }, (_value, index) => (
<span key={index} />
))}
</div>
</div>
);
}
function DetailHeader({
type,
readOnly,
typeDisabled,
onAddItem,
onEditType,
onToggleType,
}: {
type: DictTypeDetail;
readOnly: boolean;
typeDisabled: boolean;
onAddItem: () => void;
onEditType: () => void;
onToggleType: () => void;
}): ReactNode {
return (
<div className="lx-dict-head">
<span className={`lx-dict-head__avatar${typeDisabled ? ' is-gray' : ''}`}>
<DictGroupIcon group={type.group} size={22} />
</span>
<div className="lx-dict-head__main">
<div className="lx-dict-head__name">
<b>{type.name}</b>
<Tag className={type.builtin ? 'lx-dict-tag is-blue' : 'lx-dict-tag'} size="small">
{type.builtin ? '内置' : '自定义'}
</Tag>
<Tag className="lx-dict-tag" size="small">
{dictTypeKindLabel(type.kind)}
</Tag>
<Tag className={type.industryScoped ? 'lx-dict-tag is-blue' : 'lx-dict-tag'} size="small">
{type.industryScoped ? '行业作用域' : '全平台'}
</Tag>
<Tag
className={type.status === 'active' ? 'lx-dict-tag is-green' : 'lx-dict-tag is-grey'}
size="small"
>
{type.status === 'active' ? '启用' : '停用'}
</Tag>
</div>
<div className="lx-dict-head__meta">
<code>{type.code}</code>
<i>·</i>
<span>{groupNameOf(type.group)}</span>
<i>·</i>
<span>{type.itemCount} 个条目</span>
<i>·</i>
<span>{type.industryCount} 个行业覆盖</span>
</div>
<div className="lx-dict-head__meta">
<span>排序 {type.sortOrder}</span>
<i>·</i>
<span>
最近更新 {type.updatedBy ?? '—'} {formatDictDateTime(type.updatedAt)}
</span>
</div>
{type.description.trim() === '' ? null : (
<p className="lx-dict-head__desc">{type.description}</p>
)}
</div>
<div className="lx-dict-head__actions">
{readOnly ? (
<span className="lx-dict-readonly-chip">
<IconView size={14} />
只读查看
</span>
) : (
<>
<Button
size="small"
type="primary"
theme="solid"
icon={<IconAdd size={14} />}
disabled={typeDisabled}
onClick={onAddItem}
>
新增条目
</Button>
<Button
size="small"
icon={<IconEdit size={14} />}
disabled={typeDisabled}
onClick={onEditType}
>
编辑类型
</Button>
{typeDisabled ? (
<Button size="small" icon={<IconPlay size={14} />} onClick={onToggleType}>
启用类型
</Button>
) : (
<Button
size="small"
type="danger"
theme="light"
icon={<IconPause size={14} />}
onClick={onToggleType}
>
停用类型
</Button>
)}
</>
)}
</div>
</div>
);
}
function ScopeBar({
type,
industryId,
options,
items,
onIndustryChange,
}: {
type: DictTypeDetail;
industryId: string | null;
options: readonly DictScopeOption[];
items: DictItemListResponse;
onIndustryChange: (industryId: string | null) => void;
}): ReactNode {
if (!type.industryScoped) {
return (
<div className="lx-dict-scope">
<span className="lx-dict-scope__label">行业视角</span>
<span className="lx-dict-scope__unsupported">
<IconAlert size={14} />
全平台通用 · 该类型不支持行业扩展
</span>
<span className="lx-dict-layer-chip is-on">
通用 <b>{items.effectiveCount}</b>
</span>
<span className="lx-dict-scope__hint">
<IconAlert size={13} />
行业包只覆盖差异项
</span>
</div>
);
}
const chips = dictScopeChips(items, { scoped: industryId !== null });
return (
<div className="lx-dict-scope">
<span className="lx-dict-scope__label">行业视角</span>
<Select
className="lx-dict-scope__select"
size="small"
value={industryId ?? 'all'}
optionList={[
{ value: 'all', label: '全平台' },
...options.map((option) => ({ value: option.value ?? 'all', label: option.label })),
]}
onChange={(value) => {
onIndustryChange(typeof value === 'string' && value !== 'all' ? value : null);
}}
/>
<span className="lx-dict-scope__layers">
{chips.map((chip, index) => (
<span key={chip.source} className="lx-dict-layer-wrap">
{index > 0 ? <i className="lx-dict-layer-plus">+</i> : null}
<span className={`lx-dict-layer-chip${chip.count > 0 ? ' is-on' : ''}`}>
{chip.label} <b>{chip.count}</b>
</span>
</span>
))}
<i className="lx-dict-layer-plus">=</i>
<span className="lx-dict-layer-chip is-total">
生效 <b>{items.effectiveCount}</b>
</span>
</span>
<span className="lx-dict-scope__hint">
<IconAlert size={13} />
同编码以更具体层覆盖
</span>
</div>
);
}
function ViewRow({
view,
items,
onViewChange,
}: {
view: 'tree' | 'list';
items: DictItemListResponse;
onViewChange: (view: 'tree' | 'list') => void;
}): ReactNode {
const stats = dictItemViewStats(items);
return (
<div className="lx-dict-viewrow">
<RadioGroup
type="button"
buttonSize="small"
value={view}
options={[
{ value: 'tree', label: `树视图 ${stats.total}` },
{ value: 'list', label: `词表视图 ${stats.total}` },
]}
onChange={(event) => {
const next = String(event.target.value);
if (next === 'tree' || next === 'list') onViewChange(next);
}}
/>
<span className="lx-dict-viewrow__hint">
生效 <b>{stats.active}</b> 项 · 继承 {stats.inherited} · 覆盖 {stats.overridden}
{stats.disabled > 0 ? ` · 停用 ${stats.disabled}` : ''}
</span>
{stats.overridden > 0 ? (
<span className="lx-dict-override-pill">{stats.overridden} 项被更具体层覆盖</span>
) : null}
</div>
);
}
function EmptyItems({
type,
readOnly,
industryLabel,
onAddItem,
}: {
type: DictTypeDetail;
readOnly: boolean;
industryLabel: string;
onAddItem: () => void;
}): ReactNode {
return (
<div className="lx-dict-empty">
<PageEmpty
title="当前视角下暂无条目"
description={`「${industryLabel}」视角没有生效条目;可切换行业视角或新增条目。`}
action={
readOnly || type.status === 'disabled' ? undefined : (
<Button type="primary" theme="solid" icon={<IconAdd size={14} />} onClick={onAddItem}>
新增条目
</Button>
)
}
/>
</div>
);
}
export interface DictDetailPanelProps {
type: DictTypeDetail | undefined;
typeLoading: boolean;
typeError: Error | null;
items: DictItemListResponse | undefined;
itemsLoading: boolean;
itemsError: Error | null;
view: 'tree' | 'list';
industryId: string | null;
industryOptions: readonly DictScopeOption[];
readOnly: boolean;
onRetry: () => void;
onViewChange: (view: 'tree' | 'list') => void;
onIndustryChange: (industryId: string | null) => void;
onAddItem: () => void;
onEditType: () => void;
onToggleType: () => void;
onViewItem: (item: DictItem) => void;
onEditItem: (item: DictItem) => void;
onToggleItem: (item: DictItem) => void;
}
/** 右栏:类型头 + 行业作用域 + 树/词表切换 + 条目区 + 底部 R-10 提示 */
export function DictDetailPanel({
type,
typeLoading,
typeError,
items,
itemsLoading,
itemsError,
view,
industryId,
industryOptions,
readOnly,
onRetry,
onViewChange,
onIndustryChange,
onAddItem,
onEditType,
onToggleType,
onViewItem,
onEditItem,
onToggleItem,
}: DictDetailPanelProps): ReactNode {
const response = items ?? EMPTY_ITEMS;
const industryLabel = useMemo(() => {
if (industryId === null) return '全平台';
return industryOptions.find((option) => option.value === industryId)?.label ?? industryId;
}, [industryId, industryOptions]);
if (typeLoading) {
return (
<section className="lx-dict-detail">
<DetailSkeleton />
</section>
);
}
if (typeError !== null) {
return (
<section className="lx-dict-detail">
<ErrorState
title="字典详情加载失败"
message={typeError.message}
onRetry={onRetry}
className="lx-dict-detail__state"
/>
</section>
);
}
if (type === undefined) {
return (
<section className="lx-dict-detail">
<PageEmpty
title="选择左侧字典类型查看详情"
description="支持树视图与词表视图、行业三层合并与条目维护。"
/>
</section>
);
}
const typeDisabled = type.status === 'disabled';
return (
<section className="lx-dict-detail">
{typeDisabled ? (
<div className="lx-dict-banner">
<IconPause size={15} />
<div>
<b>该字典类型已停用</b>
<span>
停用后新数据不可选该类型条目;历史引用保留快照,历史统计按快照解释(R-10)。
</span>
</div>
</div>
) : null}
{readOnly ? (
<div className="lx-dict-banner is-muted">
<IconView size={15} />
<div>
<b>当前账号无字典写权限(dict:manage)</b>
<span>仅可查看类型、切换视图与行业视角,不可新增或修改条目。</span>
</div>
</div>
) : null}
<DetailHeader
type={type}
readOnly={readOnly}
typeDisabled={typeDisabled}
onAddItem={onAddItem}
onEditType={onEditType}
onToggleType={onToggleType}
/>
<ScopeBar
type={type}
industryId={industryId}
options={industryOptions}
items={response}
onIndustryChange={onIndustryChange}
/>
<ViewRow view={view} items={response} onViewChange={onViewChange} />
<div className="lx-dict-items">
{itemsLoading ? <DetailSkeleton /> : null}
{!itemsLoading && itemsError !== null ? (
<ErrorState
title="字典条目加载失败"
message={itemsError.message}
onRetry={onRetry}
className="lx-dict-detail__state"
/>
) : null}
{!itemsLoading && itemsError === null && response.items.length === 0 ? (
<EmptyItems
type={type}
readOnly={readOnly}
industryLabel={industryLabel}
onAddItem={onAddItem}
/>
) : null}
{!itemsLoading && itemsError === null && response.items.length > 0 ? (
<DictItemsView
items={response.items}
view={view}
readOnly={readOnly}
typeDisabled={typeDisabled}
onView={onViewItem}
onEdit={onEditItem}
onToggle={onToggleItem}
/>
) : null}
</div>
<div className="lx-dict-footer">
<span className="lx-dict-footer__lock">编码创建后不可修改</span>
<span className="lx-dict-footer__sep">·</span>
<span>变更只影响新数据,历史统计按快照解释(R-10)</span>
<span className="lx-dict-footer__right">
生效 {response.effectiveCount} 项 · 停用 {response.disabledCount} 项
</span>
</div>
</section>
);
}