Commit c536e180 authored by David Yang's avatar David Yang

feat: applications 切换

parent 88f229a6
{ {
"name": "problem_rectification_new", "name": "problem_rectification_new",
"version": "1.0.3", "version": "1.0.4",
"build": "abc", "build": "abc",
"description": "merge master", "description": "merge master",
"date": "2025-07-14 19:27:20", "date": "2025-07-14 19:27:20",
......
import { useEffect, useMemo, useState } from 'react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/catalyst-ui-kit/javascript/table';
import { loadSubject, queryRecords } from '@/services/DataService';
import {
formatFieldValue,
getFieldValue,
getReferenceSubjectName,
normalizeRecordId,
toArray,
} from '@/utils/subjectView';
const TITLE_COLUMN = {
field: 'title',
label: '标题',
fieldDef: { name: 'title', label: '标题', type: 'text' },
};
function hasRecordBody(value) {
return value && typeof value === 'object' && Boolean(value.metadata || value.title || value.name);
}
function getReferenceIds(value) {
return toArray(value).map(normalizeRecordId).filter(Boolean);
}
function inferColumnsFromRecords(records) {
const metadataKeys = [];
const seenKeys = new Set();
records.forEach((record) => {
Object.keys(record?.metadata || {}).forEach((key) => {
if (!seenKeys.has(key)) {
seenKeys.add(key);
metadataKeys.push(key);
}
});
});
const metadataColumns = metadataKeys.map((key) => ({
field: `metadata.${key}`,
label: key,
fieldDef: { name: key, label: key, type: 'text' },
}));
return metadataColumns.length > 0 ? metadataColumns : [TITLE_COLUMN];
}
function buildColumns(subject, records) {
if (!subject?.fields) {
return inferColumnsFromRecords(records);
}
const metadataColumns = toArray(subject?.fields).map((field) => ({
field: `metadata.${field.name}`,
label: field.label || field.name,
fieldDef: field,
}));
return metadataColumns.length > 0 ? metadataColumns : [TITLE_COLUMN];
}
function sortRecordsByReferenceOrder(records, referenceIds) {
const orderMap = new Map(referenceIds.map((id, index) => [id, index]));
return [...records].sort((left, right) => {
const leftOrder = orderMap.get(normalizeRecordId(left)) ?? Number.MAX_SAFE_INTEGER;
const rightOrder = orderMap.get(normalizeRecordId(right)) ?? Number.MAX_SAFE_INTEGER;
return leftOrder - rightOrder;
});
}
function CellValue({ value, fieldDef }) {
const text = formatFieldValue(value, fieldDef);
if (!text) {
return <span className="text-zinc-400">-</span>;
}
if (fieldDef?.type === 'object' || fieldDef?.type === 'form' || fieldDef?.type === 'subject') {
return (
<span className="block max-w-80 truncate font-mono text-xs text-zinc-600" title={text}>
{text}
</span>
);
}
return (
<span className="block max-w-80 whitespace-normal break-words text-zinc-700">
{text}
</span>
);
}
export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000 }) {
const subjectName = getReferenceSubjectName(fieldDef);
const referenceIds = useMemo(() => getReferenceIds(value), [value]);
const populatedRecords = useMemo(
() => toArray(value).filter(hasRecordBody),
[value],
);
const [subject, setSubject] = useState(null);
const [records, setRecords] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
let cancelled = false;
const loadReferenceData = async () => {
setError('');
setLoading(false);
setSubject(null);
setRecords([]);
if (referenceIds.length === 0) {
return;
}
if (!subjectName) {
setRecords(populatedRecords);
return;
}
setLoading(true);
try {
const shouldQueryRecords = populatedRecords.length < referenceIds.length;
const [subjectData, recordsResult] = await Promise.all([
loadSubject(subjectName),
shouldQueryRecords
? queryRecords({
subject: subjectName,
page: 0,
page_size: pageSize,
filter: {
_id: { $in: referenceIds },
},
mode: 'full',
})
: Promise.resolve({ records: populatedRecords }),
]);
if (cancelled) {
return;
}
const loadedRecords = recordsResult?.records || populatedRecords;
setSubject(subjectData || null);
setRecords(sortRecordsByReferenceOrder(loadedRecords, referenceIds));
} catch (requestError) {
if (!cancelled) {
setError(requestError?.message || '引用记录加载失败,请稍后重试。');
setSubject(null);
setRecords(populatedRecords);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
loadReferenceData();
return () => {
cancelled = true;
};
}, [pageSize, populatedRecords, referenceIds, subjectName]);
if (referenceIds.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-zinc-200 bg-zinc-50 px-4 py-5 text-sm text-zinc-500">
暂无关联记录
</div>
);
}
if (!subjectName && populatedRecords.length === 0) {
return (
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
引用字段缺少目标 Subject 配置,暂不能加载关联明细。
</div>
);
}
if (loading) {
return (
<div className="rounded-2xl border border-zinc-200 bg-white px-4 py-5 text-sm text-zinc-500 shadow-sm">
正在加载关联明细...
</div>
);
}
const columns = buildColumns(subject, records);
return (
<div className="space-y-2">
{error ? (
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
{error}
</div>
) : null}
<div className="overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm">
<div className="border-b border-zinc-100 px-4 py-3 text-xs text-zinc-500">
共 {records.length} 条关联记录
</div>
{records.length === 0 ? (
<div className="px-4 py-5 text-sm text-zinc-500">暂无可展示的关联明细</div>
) : (
<Table dense grid className="max-h-96 overflow-auto">
<TableHead>
<TableRow>
{columns.map((column) => (
<TableHeader key={column.field} className="bg-zinc-50 text-xs font-semibold text-zinc-600">
{column.label}
</TableHeader>
))}
</TableRow>
</TableHead>
<TableBody>
{records.map((record, rowIndex) => (
<TableRow key={normalizeRecordId(record) || `${record?.name || 'reference'}-${rowIndex}`}>
{columns.map((column) => (
<TableCell key={column.field} className="align-top text-sm">
<CellValue
value={getFieldValue(record, column.field)}
fieldDef={column.fieldDef}
/>
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</div>
);
}
...@@ -6,10 +6,12 @@ import { Select } from '@/catalyst-ui-kit/javascript/select'; ...@@ -6,10 +6,12 @@ import { Select } from '@/catalyst-ui-kit/javascript/select';
import { Textarea } from '@/catalyst-ui-kit/javascript/textarea'; import { Textarea } from '@/catalyst-ui-kit/javascript/textarea';
import { queryUsers, uploadFile } from '@/services/DataService'; import { queryUsers, uploadFile } from '@/services/DataService';
import ReferenceSearchSelect from './ReferenceSearchSelect'; import ReferenceSearchSelect from './ReferenceSearchSelect';
import ReferenceRecordsTable from './ReferenceRecordsTable';
import { import {
dateInputValue, dateInputValue,
formatFieldValue, formatFieldValue,
getSelectOptions, getSelectOptions,
isDateFieldType,
isReferenceFieldType, isReferenceFieldType,
normalizeUserLabel, normalizeUserLabel,
toArray, toArray,
...@@ -222,6 +224,7 @@ export default function TailwindEditableField({ ...@@ -222,6 +224,7 @@ export default function TailwindEditableField({
const canEdit = !readonly && !disabled; const canEdit = !readonly && !disabled;
const fieldType = fieldDef?.type || 'text'; const fieldType = fieldDef?.type || 'text';
const isDateField = isDateFieldType(fieldType);
const isLongText = ['long_text', 'rich_text'].includes(fieldType); const isLongText = ['long_text', 'rich_text'].includes(fieldType);
const isJsonLike = ['object', 'form', 'subject'].includes(fieldType); const isJsonLike = ['object', 'form', 'subject'].includes(fieldType);
...@@ -300,6 +303,33 @@ export default function TailwindEditableField({ ...@@ -300,6 +303,33 @@ export default function TailwindEditableField({
}; };
if (!editing) { if (!editing) {
if (isReferenceFieldType(fieldType)) {
return (
<div
className={clsx(
'group min-h-10 w-full rounded-2xl px-3 py-2.5 text-left text-sm transition',
canEdit
? 'bg-transparent hover:bg-rose-50/70 hover:ring-1 hover:ring-rose-100'
: 'bg-transparent opacity-80',
)}
>
<ReferenceRecordsTable value={value} fieldDef={fieldDef} />
<div className="mt-2 flex items-center justify-between gap-3">
{disabled ? <div className="text-xs text-amber-600">当前视图无编辑权限</div> : <span />}
{canEdit ? (
<button
type="button"
onClick={() => setEditing(true)}
className="text-[11px] font-medium text-zinc-400 transition hover:text-rose-700 group-hover:text-rose-600"
>
点击编辑引用
</button>
) : null}
</div>
</div>
);
}
return ( return (
<button <button
type="button" type="button"
...@@ -497,8 +527,8 @@ export default function TailwindEditableField({ ...@@ -497,8 +527,8 @@ export default function TailwindEditableField({
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<Input <Input
type={fieldType === 'number' ? 'number' : fieldType === 'date' ? 'date' : 'text'} type={fieldType === 'number' ? 'number' : isDateField ? 'date' : 'text'}
value={fieldType === 'date' ? dateInputValue(draft) : draft ?? ''} value={isDateField ? dateInputValue(draft) : draft ?? ''}
onChange={(event) => { onChange={(event) => {
const nextValue = fieldType === 'number' const nextValue = fieldType === 'number'
? event.target.value === '' ? '' : Number(event.target.value) ? event.target.value === '' ? '' : Number(event.target.value)
......
...@@ -7,6 +7,7 @@ import { ...@@ -7,6 +7,7 @@ import {
} from '@/services/DataService'; } from '@/services/DataService';
import { import {
isFieldDisabledByView, isFieldDisabledByView,
isDateFieldType,
isReferenceFieldType, isReferenceFieldType,
setFieldValue, setFieldValue,
toIsoDateString, toIsoDateString,
...@@ -28,7 +29,7 @@ function transformSaveValue(newValue, fieldDef) { ...@@ -28,7 +29,7 @@ function transformSaveValue(newValue, fieldDef) {
return newValue?._id || newValue; return newValue?._id || newValue;
} }
if (fieldDef?.type === 'date') { if (isDateFieldType(fieldDef?.type)) {
return toIsoDateString(newValue); return toIsoDateString(newValue);
} }
......
This diff is collapsed.
import ApplicationSwitcherDialog from '@/pages/problem_rectification/ApplicationSwitcherDialog';
import xyLogoSmall from '@/assets/xy-logo_small.png'; import xyLogoSmall from '@/assets/xy-logo_small.png';
import { UserContext } from '@/wrapper/Auth'; import { UserContext } from '@/wrapper/Auth';
import { SiteContext } from '@/wrapper/Site';
import { import {
ArchiveBoxIcon, ArchiveBoxIcon,
Bars3Icon, Bars3Icon,
ClipboardDocumentCheckIcon, ClipboardDocumentCheckIcon,
HomeModernIcon, HomeModernIcon,
Squares2X2Icon,
XMarkIcon, XMarkIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import clsx from 'clsx'; import clsx from 'clsx';
...@@ -180,7 +183,9 @@ function MobileSidebar({ open, close, children }) { ...@@ -180,7 +183,9 @@ function MobileSidebar({ open, close, children }) {
export default function ProblemRectificationLayout() { export default function ProblemRectificationLayout() {
const location = useLocation(); const location = useLocation();
const userInfo = useContext(UserContext); const userInfo = useContext(UserContext);
const siteInfo = useContext(SiteContext);
const [isMobile, setIsMobile] = useState(false); const [isMobile, setIsMobile] = useState(false);
const [isApplicationDialogOpen, setIsApplicationDialogOpen] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false); const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
...@@ -224,6 +229,21 @@ export default function ProblemRectificationLayout() { ...@@ -224,6 +229,21 @@ export default function ProblemRectificationLayout() {
); );
}, [location.pathname]); }, [location.pathname]);
const applicationSwitcherButton = (
<button
type="button"
onClick={() => setIsApplicationDialogOpen(true)}
aria-label="Open application switcher"
title="应用切换"
className={clsx(
'inline-flex items-center justify-center rounded-[20px] border border-[#f97316]/70 bg-[linear-gradient(180deg,rgba(39,39,42,0.92),rgba(24,24,27,0.96))] text-[#fb923c] shadow-[0_16px_30px_rgba(249,115,22,0.16)] transition duration-200 hover:-translate-y-0.5 hover:border-[#fb923c] hover:text-[#fdba74] hover:shadow-[0_18px_36px_rgba(249,115,22,0.26)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#fb923c]/50',
sidebarCollapsed ? 'size-11' : 'size-14',
)}
>
<Squares2X2Icon className={clsx(sidebarCollapsed ? 'size-5' : 'size-7')} />
</button>
);
return ( return (
<div <div
className="flex h-screen overflow-hidden bg-[radial-gradient(circle_at_top_left,_rgba(251,191,188,0.22),_transparent_26%),radial-gradient(circle_at_bottom_right,_rgba(251,207,232,0.2),_transparent_30%),#f8fafc]" className="flex h-screen overflow-hidden bg-[radial-gradient(circle_at_top_left,_rgba(251,191,188,0.22),_transparent_26%),radial-gradient(circle_at_bottom_right,_rgba(251,207,232,0.2),_transparent_30%),#f8fafc]"
...@@ -239,27 +259,34 @@ export default function ProblemRectificationLayout() { ...@@ -239,27 +259,34 @@ export default function ProblemRectificationLayout() {
> >
<div <div
className={clsx( className={clsx(
'mb-7 flex items-center', 'mb-7',
sidebarCollapsed ? 'justify-center' : 'gap-3 px-1', sidebarCollapsed
? 'flex flex-col items-center gap-3'
: 'flex items-start justify-between gap-4 px-1',
)} )}
> >
{sidebarCollapsed ? ( {sidebarCollapsed ? (
<img <>
src={xyLogoSmall} <img
alt="XY Logo" src={xyLogoSmall}
className="h-8 w-auto max-w-8 shrink-0 object-contain" alt="XY Logo"
/> className="h-8 w-auto max-w-8 shrink-0 object-contain"
) : null} />
{!sidebarCollapsed ? ( {applicationSwitcherButton}
<div className="min-w-0"> </>
<h1 className="truncate text-sm font-semibold tracking-[0.02em] text-white/95"> ) : (
{APP_NAME} <>
</h1> <div className="min-w-0 flex-1">
<div className="mt-1 text-xs text-zinc-400"> <h1 className="truncate text-sm font-semibold tracking-[0.02em] text-white/95">
替代旧多维表格版问题整改应用 {APP_NAME}
</h1>
<div className="mt-1 text-xs text-zinc-400">
替代旧多维表格版问题整改应用
</div>
</div> </div>
</div> {applicationSwitcherButton}
) : null} </>
)}
</div> </div>
<nav className="flex-1 space-y-5 overflow-y-auto"> <nav className="flex-1 space-y-5 overflow-y-auto">
...@@ -365,12 +392,26 @@ export default function ProblemRectificationLayout() { ...@@ -365,12 +392,26 @@ export default function ProblemRectificationLayout() {
<Bars3Icon className="size-5" /> <Bars3Icon className="size-5" />
</NavbarItem> </NavbarItem>
<div className="text-sm text-zinc-500">{currentGroupTitle}</div> <div className="text-sm text-zinc-500">{currentGroupTitle}</div>
<NavbarItem
onClick={() => setIsApplicationDialogOpen(true)}
aria-label="Open application switcher"
className="border border-rose-200 bg-white text-rose-500 shadow-sm hover:bg-rose-50 hover:text-rose-600"
>
<Squares2X2Icon className="size-5" />
</NavbarItem>
</div> </div>
<div className="flex-1 overflow-y-auto rounded-[28px] border border-white/80 bg-white/80 p-4 shadow-sm backdrop-blur sm:p-6"> <div className="flex-1 overflow-y-auto rounded-[28px] border border-white/80 bg-white/80 p-4 shadow-sm backdrop-blur sm:p-6">
<Outlet /> <Outlet />
</div> </div>
</main> </main>
<ApplicationSwitcherDialog
open={isApplicationDialogOpen}
onClose={() => setIsApplicationDialogOpen(false)}
userInfo={userInfo}
siteInfo={siteInfo}
/>
</div> </div>
); );
} }
...@@ -50,6 +50,7 @@ ...@@ -50,6 +50,7 @@
- 问题整改主列表使用普通数据表格,不再依赖多维表格编辑器 - 问题整改主列表使用普通数据表格,不再依赖多维表格编辑器
- 新增、编辑、查看详情优先采用抽屉或弹窗,不走重型嵌套表格交互 - 新增、编辑、查看详情优先采用抽屉或弹窗,不走重型嵌套表格交互
- 批量操作放在顶部工具栏,不再依赖右键菜单作为唯一入口 - 批量操作放在顶部工具栏,不再依赖右键菜单作为唯一入口
- 侧栏左上角提供“应用切换”入口,点击后以弹框展示当前账号可访问的全部应用,支持从问题整改模块快速跳转到其它应用工作区
### 3.3 视图配置驱动约定 ### 3.3 视图配置驱动约定
...@@ -72,6 +73,7 @@ ...@@ -72,6 +73,7 @@
- 页面本身只负责提供 subject 对应的数据加载、统计口径、筛选 schema、行操作与批量操作 - 页面本身只负责提供 subject 对应的数据加载、统计口径、筛选 schema、行操作与批量操作
- 详情侧栏统一复用 `RecordDetailDrawer`,通过 `recordName`、`subjectName`、`preferredViewName` 驱动,不再在各页面重复实现抽屉骨架 - 详情侧栏统一复用 `RecordDetailDrawer`,通过 `recordName`、`subjectName`、`preferredViewName` 驱动,不再在各页面重复实现抽屉骨架
- 详情侧栏顶部不展示记录标题、状态和摘要卡片,只保留详情视图切换与全屏 / 关闭操作,字段主体作为主要阅读区域 - 详情侧栏顶部不展示记录标题、状态和摘要卡片,只保留详情视图切换与全屏 / 关闭操作,字段主体作为主要阅读区域
- 详情侧栏内 `reference` 类型字段只读态统一展示为关联明细表:按目标 Subject 的 `title` 与全部 metadata 字段展开,字段值按各自类型格式化;编辑态仍使用引用选择器,保存时只写入关联记录 `_id` 或 `_id[]`
- 列表行操作列使用图标按钮横向排列,查看、复制、删除等常用操作通过 tooltip / aria-label 标明含义,避免文字按钮纵向堆叠 - 列表行操作列使用图标按钮横向排列,查看、复制、删除等常用操作通过 tooltip / aria-label 标明含义,避免文字按钮纵向堆叠
- 新增同类列表页时,优先复用工作台与详情抽屉组合,避免再次复制问题整改页 JSX - 新增同类列表页时,优先复用工作台与详情抽屉组合,避免再次复制问题整改页 JSX
......
...@@ -193,6 +193,10 @@ export function isReferenceFieldType(fieldType) { ...@@ -193,6 +193,10 @@ export function isReferenceFieldType(fieldType) {
return REFERENCE_FIELD_TYPES.has(fieldType); return REFERENCE_FIELD_TYPES.has(fieldType);
} }
export function isDateFieldType(fieldType) {
return ['date', 'datetime', 'date_time'].includes(normalizeText(fieldType).toLowerCase());
}
export function normalizeRecordId(value) { export function normalizeRecordId(value) {
if (!value) { if (!value) {
return ''; return '';
...@@ -423,7 +427,7 @@ export function formatFieldValue(value, fieldDef) { ...@@ -423,7 +427,7 @@ export function formatFieldValue(value, fieldDef) {
return ''; return '';
} }
if (fieldDef?.type === 'date') { if (isDateFieldType(fieldDef?.type)) {
return formatDateText(value); return formatDateText(value);
} }
...@@ -506,7 +510,7 @@ export function normalizeValueForSubmit(value, fieldDef) { ...@@ -506,7 +510,7 @@ export function normalizeValueForSubmit(value, fieldDef) {
return null; return null;
} }
if (fieldDef?.type === 'date') { if (isDateFieldType(fieldDef?.type)) {
return toIsoDateString(value); return toIsoDateString(value);
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment