Skip to content
Projects
Groups
Snippets
Help
Loading...
Sign in / Register
Toggle navigation
P
problem_rectification
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Registry
Registry
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
David Yang
problem_rectification
Commits
c536e180
Commit
c536e180
authored
Jun 15, 2026
by
David Yang
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: applications 切换
parent
88f229a6
Hide whitespace changes
Inline
Side-by-side
Showing
8 changed files
with
828 additions
and
23 deletions
+828
-23
manifest.json
public/manifest.json
+1
-1
ReferenceRecordsTable.jsx
...s/RecordDetailDrawer/components/ReferenceRecordsTable.jsx
+244
-0
TailwindEditableField.jsx
...s/RecordDetailDrawer/components/TailwindEditableField.jsx
+32
-2
useSubjectRecord.js
src/pages/RecordDetailDrawer/hooks/useSubjectRecord.js
+2
-1
ApplicationSwitcherDialog.jsx
...pages/problem_rectification/ApplicationSwitcherDialog.jsx
+483
-0
Layout.jsx
src/pages/problem_rectification/Layout.jsx
+58
-17
problem-rectification.business-flow.md
...blem_rectification/problem-rectification.business-flow.md
+2
-0
subjectView.js
src/utils/subjectView.js
+6
-2
No files found.
public/manifest.json
View file @
c536e180
{
{
"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"
,
...
...
src/pages/RecordDetailDrawer/components/ReferenceRecordsTable.jsx
0 → 100644
View file @
c536e180
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
>
);
}
src/pages/RecordDetailDrawer/components/TailwindEditableField.jsx
View file @
c536e180
...
@@ -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
)
...
...
src/pages/RecordDetailDrawer/hooks/useSubjectRecord.js
View file @
c536e180
...
@@ -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
);
}
}
...
...
src/pages/problem_rectification/ApplicationSwitcherDialog.jsx
0 → 100644
View file @
c536e180
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
>
);
}
src/pages/problem_rectification/Layout.jsx
View file @
c536e180
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
>
);
);
}
}
src/pages/problem_rectification/problem-rectification.business-flow.md
View file @
c536e180
...
@@ -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
...
...
src/utils/subjectView.js
View file @
c536e180
...
@@ -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
);
}
}
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment