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);
} }
......
import { getApplications } from '@/services/DataService';
import * as Headless from '@headlessui/react';
import {
ArrowTopRightOnSquareIcon,
ExclamationCircleIcon,
SparklesIcon,
} from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { useEffect, useMemo, useState } from 'react';
function normalizeLabelList(items) {
if (!Array.isArray(items)) {
return [];
}
return items
.map((item) => {
if (typeof item === 'string') {
return item;
}
return item?.display_name || item?.title || item?.name || '';
})
.filter(Boolean);
}
function resolveApplicationUrl(application) {
if (application?.name) {
return `/${application.name}`;
}
return '#';
}
function normalizeApplication(application, fallback = {}) {
const name = application?.name || fallback?.name || '';
return {
name,
title: application?.title || fallback?.title || application?.name || fallback?.name || '未命名应用',
description:
application?.description ||
fallback?.description ||
fallback?.title ||
application?.title ||
'进入对应应用继续处理业务',
logo: application?.logo || fallback?.logo || null,
url: resolveApplicationUrl({
name,
}),
access: application?.access || fallback?.access || '',
status: application?.status || fallback?.status || '',
type: application?.type || fallback?.type || '',
order: application?.order ?? fallback?.order ?? 0,
roles: normalizeLabelList(application?.roles?.length ? application.roles : fallback?.roles),
groups: normalizeLabelList(application?.groups?.length ? application.groups : fallback?.groups),
};
}
function formatAccessLabel(access) {
if (access === 'public') {
return '公开';
}
if (access === 'protected') {
return '受限';
}
if (access === 'private') {
return '私有';
}
if (access === 'internal') {
return '内部';
}
return '';
}
function mergeApplications(fetchedApplications, fallbackApplications) {
const fallbackMap = new Map(
fallbackApplications.map((application) => [application.name, application]),
);
const normalizedFetched = fetchedApplications
.map((application) =>
normalizeApplication(application, fallbackMap.get(application?.name)),
)
.filter((application) => application.name);
const mergedMap = new Map(
normalizedFetched.map((application) => [application.name, application]),
);
fallbackApplications.forEach((application) => {
if (!application?.name || mergedMap.has(application.name)) {
return;
}
mergedMap.set(application.name, normalizeApplication(application, application));
});
return Array.from(mergedMap.values()).sort((left, right) => {
const orderDifference = Number(left.order || 0) - Number(right.order || 0);
if (orderDifference !== 0) {
return orderDifference;
}
return left.title.localeCompare(right.title, 'zh-CN');
});
}
function buildApplicationSections(applications, applicationGroups) {
if (!Array.isArray(applicationGroups) || applicationGroups.length === 0) {
return [
{
key: 'all',
label: '全部应用',
applications,
},
];
}
const applicationMap = new Map(
applications.map((application) => [application.name, application]),
);
const groupedApplicationNames = new Set();
const sections = applicationGroups
.map((group, index) => {
const groupApplications = Array.isArray(group?.applications)
? group.applications
.map((name) => {
const application = applicationMap.get(name);
if (application) {
groupedApplicationNames.add(name);
}
return application;
})
.filter(Boolean)
: [];
return {
key: group?.label || `group-${index}`,
label: group?.label || '应用分组',
applications: groupApplications,
};
})
.filter((section) => section.applications.length > 0);
const ungroupedApplications = applications.filter(
(application) => !groupedApplicationNames.has(application.name),
);
if (ungroupedApplications.length > 0) {
sections.push({
key: 'ungrouped',
label: '其他应用',
applications: ungroupedApplications,
});
}
return sections;
}
function isCurrentApplication(application) {
if (typeof window === 'undefined') {
return false;
}
const currentPath = window.location.pathname;
const applicationRoot = application?.name ? `/${application.name}` : '';
if (application?.url?.startsWith('http')) {
try {
const url = new URL(application.url);
return url.pathname === currentPath;
} catch (error) {
return false;
}
}
return (
application?.url === currentPath ||
(!!applicationRoot &&
(currentPath === applicationRoot ||
currentPath.startsWith(`${applicationRoot}/`)))
);
}
function ApplicationCard({ application }) {
const current = isCurrentApplication(application);
const target = application?.url?.startsWith('http') ? '_blank' : undefined;
const accessLabel = formatAccessLabel(application.access);
const firstRole = application.roles?.[0];
const firstGroup = application.groups?.[0];
return (
<a
href={application.url}
target={target}
rel={target ? 'noreferrer' : undefined}
className={clsx(
'group relative min-h-[84px] overflow-hidden rounded-2xl border border-zinc-200/80 bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(248,250,252,0.94))] p-2.5 shadow-[0_10px_24px_rgba(15,23,42,0.06)] transition duration-200 ease-out hover:-translate-y-0.5 hover:border-rose-200 hover:shadow-[0_16px_32px_rgba(244,63,94,0.12)] focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/60',
current && 'border-rose-300 bg-[linear-gradient(180deg,rgba(255,241,242,0.95),rgba(255,255,255,0.96))]',
)}
>
<div className="absolute inset-x-0 top-0 h-14 bg-[radial-gradient(circle_at_top_right,rgba(251,113,133,0.14),transparent_50%)] opacity-0 transition duration-200 group-hover:opacity-100" />
<div className="relative flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-2.5">
{application.logo ? (
<img
src={application.logo}
alt={application.title}
className="size-9 rounded-xl border border-white/80 bg-white object-cover shadow-sm"
/>
) : (
<div className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-[linear-gradient(135deg,#b42318,#f43f5e)] text-sm font-semibold text-white shadow-[0_8px_18px_rgba(180,35,24,0.22)]">
{application.title.slice(0, 1)}
</div>
)}
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-slate-950">
{application.title}
</div>
<div className="mt-0.5 truncate text-[11px] leading-4 text-slate-500">
{application.description}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{current ? (
<span className="rounded-full bg-rose-100 px-2 py-0.5 text-[11px] font-medium text-rose-700">
当前
</span>
) : null}
<span className="rounded-full bg-slate-100 p-1.5 text-slate-500 transition group-hover:bg-rose-50 group-hover:text-rose-600">
<ArrowTopRightOnSquareIcon className="size-3.5" />
</span>
</div>
</div>
{accessLabel || firstRole || firstGroup ? (
<div className="relative mt-2 flex flex-wrap gap-1.5">
{accessLabel ? (
<span className="rounded-full bg-zinc-100 px-2 py-0.5 text-[11px] font-medium text-zinc-700">
{accessLabel}
</span>
) : null}
{firstRole ? (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-700">
{firstRole}
</span>
) : null}
{firstGroup ? (
<span className="rounded-full bg-rose-50 px-2 py-0.5 text-[11px] font-medium text-rose-700">
{firstGroup}
</span>
) : null}
</div>
) : null}
</a>
);
}
function LoadingSkeleton() {
return (
<div className="grid gap-2.5 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-6">
{Array.from({ length: 6 }).map((_, index) => (
<div
key={index}
className="min-h-[84px] rounded-2xl border border-zinc-200 bg-white/90 p-2.5 shadow-[0_10px_24px_rgba(15,23,42,0.05)]"
>
<div className="animate-pulse">
<div className="flex items-start gap-3">
<div className="size-9 rounded-xl bg-zinc-200" />
<div className="min-w-0 flex-1 space-y-2">
<div className="h-4 w-2/5 rounded-full bg-zinc-200" />
<div className="h-3 w-full rounded-full bg-zinc-100" />
</div>
</div>
<div className="mt-2 flex gap-1.5">
<div className="h-5 w-12 rounded-full bg-zinc-100" />
<div className="h-5 w-14 rounded-full bg-zinc-100" />
</div>
</div>
</div>
))}
</div>
);
}
export default function ApplicationSwitcherDialog({
open,
onClose,
userInfo,
siteInfo,
}) {
const fallbackApplications = useMemo(() => {
if (!Array.isArray(userInfo?.applications)) {
return [];
}
return userInfo.applications
.map((application) =>
normalizeApplication(application, {
...application,
description: application?.title,
}),
)
.filter((application) => application.name);
}, [userInfo]);
const [applications, setApplications] = useState(fallbackApplications);
const [status, setStatus] = useState(
fallbackApplications.length > 0 ? 'success' : 'idle',
);
const [errorMessage, setErrorMessage] = useState('');
const applicationSections = useMemo(() => {
return buildApplicationSections(applications, siteInfo?.group);
}, [applications, siteInfo?.group]);
useEffect(() => {
if (fallbackApplications.length === 0) {
return;
}
setApplications((previous) => {
if (previous.length > 0) {
return previous;
}
return fallbackApplications;
});
}, [fallbackApplications]);
useEffect(() => {
if (!open) {
return;
}
let cancelled = false;
const loadApplications = async () => {
setStatus((previous) => (previous === 'success' ? previous : 'loading'));
setErrorMessage('');
try {
const response = await getApplications();
const fetchedApplications = Array.isArray(response) ? response : [];
if (cancelled) {
return;
}
const mergedApplications = mergeApplications(
fetchedApplications,
fallbackApplications,
);
setApplications(mergedApplications);
setStatus('success');
} catch (error) {
if (cancelled) {
return;
}
setApplications(fallbackApplications);
setStatus('error');
setErrorMessage('应用列表加载失败,已回退展示当前账号上的应用信息。');
}
};
loadApplications();
return () => {
cancelled = true;
};
}, [fallbackApplications, open]);
return (
<Headless.Dialog
open={open}
onClose={onClose}
className="relative z-[180]"
>
<Headless.DialogBackdrop
transition
className="fixed inset-0 bg-[rgba(15,23,42,0.48)] backdrop-blur-sm transition duration-300 ease-out data-closed:opacity-0"
/>
<div className="fixed inset-0 overflow-y-auto p-3 sm:p-4 lg:p-5">
<div className="flex min-h-full items-center justify-center">
<Headless.DialogPanel
transition
className="flex max-h-[calc(100dvh-2rem)] w-full max-w-[92rem] flex-col overflow-hidden rounded-[28px] border border-white/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(248,250,252,0.94))] p-4 shadow-[0_30px_80px_rgba(15,23,42,0.22)] ring-1 ring-black/5 transition duration-300 ease-out data-closed:translate-y-4 data-closed:scale-[0.97] data-closed:opacity-0 sm:p-5"
>
<div className="flex shrink-0 flex-col gap-3 border-b border-zinc-200/80 pb-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="inline-flex items-center gap-2 rounded-full bg-rose-50 px-2.5 py-0.5 text-[11px] font-semibold uppercase tracking-[0.18em] text-rose-600">
<SparklesIcon className="size-3.5" />
Applications
</div>
<Headless.DialogTitle className="mt-2 text-xl font-semibold tracking-tight text-slate-950 sm:text-2xl">
应用切换
</Headless.DialogTitle>
<p className="mt-1 max-w-2xl text-xs leading-5 text-slate-500">
这里展示当前账号可访问的全部应用,点击卡片即可进入对应工作区。
</p>
</div>
<div className="flex items-center gap-3">
<div className="rounded-2xl border border-rose-100 bg-rose-50/70 px-3 py-2 text-right">
<div className="text-[11px] font-medium tracking-wide text-rose-500">
可见应用
</div>
<div className="mt-0.5 text-xl font-semibold tracking-tight text-slate-950">
{applications.length}
</div>
</div>
<button
type="button"
onClick={onClose}
className="inline-flex h-10 items-center justify-center rounded-xl border border-zinc-200 bg-white px-3.5 text-sm font-medium text-slate-600 transition hover:border-zinc-300 hover:text-slate-900"
>
关闭
</button>
</div>
</div>
<div className="mt-4 min-h-0 flex-1 overflow-y-auto pr-1 [scrollbar-gutter:stable]">
{status === 'error' && errorMessage ? (
<div className="mb-4 flex items-start gap-3 rounded-2xl border border-amber-200 bg-amber-50/80 px-4 py-3 text-sm text-amber-900">
<ExclamationCircleIcon className="mt-0.5 size-5 shrink-0 text-amber-500" />
<div>{errorMessage}</div>
</div>
) : null}
{status === 'loading' && applications.length === 0 ? (
<LoadingSkeleton />
) : applications.length > 0 ? (
<div className="space-y-4">
{applicationSections.map((section) => (
<section key={section.key}>
<div className="mb-2 flex items-center gap-3">
<h3 className="text-sm font-semibold text-slate-900">
{section.label}
</h3>
<span className="h-px flex-1 bg-zinc-200/80" />
<span className="text-xs font-medium text-slate-400">
{section.applications.length}
</span>
</div>
<div className="grid gap-2.5 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-6">
{section.applications.map((application) => (
<ApplicationCard
key={application.name}
application={application}
/>
))}
</div>
</section>
))}
</div>
) : (
<div className="flex min-h-72 flex-col items-center justify-center rounded-[28px] border border-dashed border-zinc-200 bg-zinc-50/80 px-6 text-center">
<div className="text-lg font-semibold text-slate-900">
暂无可切换应用
</div>
<p className="mt-2 max-w-md text-sm leading-6 text-slate-500">
当前账号还没有分配其它应用权限,后续分配后这里会自动展示。
</p>
</div>
)}
</div>
</Headless.DialogPanel>
</div>
</div>
</Headless.Dialog>
);
}
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,19 +259,24 @@ export default function ProblemRectificationLayout() { ...@@ -239,19 +259,24 @@ 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 <img
src={xyLogoSmall} src={xyLogoSmall}
alt="XY Logo" alt="XY Logo"
className="h-8 w-auto max-w-8 shrink-0 object-contain" className="h-8 w-auto max-w-8 shrink-0 object-contain"
/> />
) : null} {applicationSwitcherButton}
{!sidebarCollapsed ? ( </>
<div className="min-w-0"> ) : (
<>
<div className="min-w-0 flex-1">
<h1 className="truncate text-sm font-semibold tracking-[0.02em] text-white/95"> <h1 className="truncate text-sm font-semibold tracking-[0.02em] text-white/95">
{APP_NAME} {APP_NAME}
</h1> </h1>
...@@ -259,7 +284,9 @@ export default function ProblemRectificationLayout() { ...@@ -259,7 +284,9 @@ export default function ProblemRectificationLayout() {
替代旧多维表格版问题整改应用 替代旧多维表格版问题整改应用
</div> </div>
</div> </div>
) : null} {applicationSwitcherButton}
</>
)}
</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