背景:用户选定方案 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 无溢出。
462 lines
14 KiB
TypeScript
462 lines
14 KiB
TypeScript
/* 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>
|
||
);
|
||
}
|