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 无溢出。
This commit is contained in:
Stev_Wang committed 2026-11-04 11:49:32 +08:00
1 parent e4330a43e4
commit 3b0a99d91f
36 files changed
+7691 -15

No files matched your search

@@ -0,0 +1,461 @@
/* 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>
);
}