Skip to content
Projects
Groups
Snippets
Help
Loading...
Sign in / Register
Toggle navigation
A
audit_project_manager
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
audit_project_manager
Commits
69823071
Commit
69823071
authored
Oct 08, 2026
by
drigle
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: unify app feedback and configure table field display
parent
8d6af315
Hide whitespace changes
Inline
Side-by-side
Showing
34 changed files
with
827 additions
and
226 deletions
+827
-226
xinyuan-design-system.md
docs/xinyuan-design-system.md
+6
-2
manifest.json
public/manifest.json
+1
-1
app.js
src/app.js
+7
-1
AppFeedback.jsx
src/components/AppFeedback.jsx
+42
-0
AppShell.jsx
src/components/AppShell.jsx
+0
-2
DynamicFormManager.jsx
src/components/DynamicFormManager.jsx
+52
-20
DynamicFormValueEditor.jsx
src/components/DynamicFormValueEditor.jsx
+13
-17
DynamicRecordDialog.jsx
src/components/DynamicRecordDialog.jsx
+17
-12
ReferenceSearchSelect.jsx
src/components/ReferenceSearchSelect.jsx
+3
-2
SubjectRecordWorkbench.jsx
src/components/SubjectRecordWorkbench.jsx
+18
-8
TableFieldValue.jsx
src/components/TableFieldValue.jsx
+42
-0
UserSearchSelect.jsx
src/components/UserSearchSelect.jsx
+10
-3
index.jsx
src/components/record-workbench/index.jsx
+59
-49
application.js
src/config/application.js
+1
-1
useFeedbackState.js
src/hooks/useFeedbackState.js
+38
-0
Login.jsx
src/pages/Login.jsx
+3
-9
ReferenceRecordsTable.jsx
...s/RecordDetailDrawer/components/ReferenceRecordsTable.jsx
+4
-7
TailwindEditableField.jsx
...s/RecordDetailDrawer/components/TailwindEditableField.jsx
+27
-9
useSubjectRecord.js
src/pages/RecordDetailDrawer/hooks/useSubjectRecord.js
+4
-2
index.jsx
src/pages/RecordDetailDrawer/index.jsx
+27
-25
ApplicationSwitcherDialog.jsx
src/pages/project_manager_xy/ApplicationSwitcherDialog.jsx
+5
-10
AuditProjectDashboardPage.jsx
src/pages/project_manager_xy/AuditProjectDashboardPage.jsx
+16
-12
AuditProjectFormPage.jsx
src/pages/project_manager_xy/AuditProjectFormPage.jsx
+13
-5
SubjectWorkbenchService.js
src/pages/project_manager_xy/SubjectWorkbenchService.js
+7
-3
AuditProjectMaterialField.jsx
...oject_manager_xy/components/AuditProjectMaterialField.jsx
+9
-8
NotificationListPage.jsx
...project_manager_xy/notifications/NotificationListPage.jsx
+23
-13
project-management.business-flow.md
...es/project_manager_xy/project-management.business-flow.md
+73
-2
record-api.md
src/services/record-api.md
+13
-0
tableFieldDisplayService.js
src/services/tableFieldDisplayService.js
+17
-0
tailwind.css
src/tailwind.css
+12
-0
subjectView.js
src/utils/subjectView.js
+5
-1
tableFieldDisplay.js
src/utils/tableFieldDisplay.js
+80
-0
Site.jsx
src/wrapper/Site.jsx
+3
-2
tableFieldDisplay.test.mjs
tests/tableFieldDisplay.test.mjs
+177
-0
No files found.
docs/xinyuan-design-system.md
View file @
69823071
...
...
@@ -266,8 +266,12 @@ Inter 与系统回退字体之间切换。
-
严谨业务界面不使用绿色系、粉色、玫红或洋红危险色;HeroUI 主色固定为鑫元基金品牌红
`#DA241A`
,由
`--accent`
/
`--primary`
提供,Hover 使用
`#C91F17`
,Pressed 使用
`#B51B14`
。
`danger`
/
`danger-soft`
继续继承品牌红深色阶,不允许被 HeroUI 默认蓝色或其它主题色覆盖。
-
详情请求失败、详情动作失败和保存失败使用 HeroUI
`toast.danger`
短提醒(约 4 秒自动消失),
不在弹窗内容流中渲染持续占位的错误 Alert;需要用户持续处理的锁定、字段校验等业务提示仍保留在上下文中。
-
详情请求、详情动作和保存反馈统一使用根部
`AppFeedback`
的 HeroUI Toast;成功默认 4 秒、错误和
警告默认 8 秒自动消失,不在弹窗内容流中渲染持续占位的接口反馈 Alert。详情保存全部成功后提示
“详情已保存。”,保留弹窗和已保存内容;锁定、字段校验等需要持续处理的业务提示保留在上下文中。
-
Toast 关闭按钮使用 HeroUI
`Button`
(
`ghost`
、
`sm`
、
`isIconOnly`
),通过官方
`slot="close"`
接入关闭行为,组合
`AppIcons.Close`
并位于正文右侧参与布局。前排提示的关闭图标始终可见,后排
堆叠提示隐藏关闭按钮;沿用官方 Hover/焦点样式,不添加带边框的放大关闭按钮、绝对定位或正文占位。
## 禁止事项
...
...
public/manifest.json
View file @
69823071
{
"name"
:
"audit_project_manager"
,
"version"
:
"1.0.
2
"
,
"version"
:
"1.0.
4
"
,
"build"
:
"abc"
,
"description"
:
"审计项目管理"
,
"date"
:
"2025-07-14 19:27:20"
,
...
...
src/app.js
View file @
69823071
import
'@/tailwind.css'
;
import
{
defineApp
}
from
'umi'
;
import
{
createElement
}
from
'react'
;
import
AppFeedback
from
'@/components/AppFeedback'
;
import
{
APPLICATION_BASE_PATH
,
APPLICATION_NAME
,
...
...
@@ -33,4 +35,8 @@ export async function getInitialState() {
};
}
export
default
defineApp
({});
export
function
rootContainer
(
container
)
{
return
createElement
(
AppFeedback
,
null
,
container
);
}
export
default
defineApp
({
rootContainer
});
src/components/AppFeedback.jsx
0 → 100644
View file @
69823071
import
{
Button
,
Toast
}
from
'@heroui/react'
;
import
{
Close
}
from
'@/components/AppIcons'
;
export
default
function
AppFeedback
({
children
})
{
return
(
<>
{
children
}
<
Toast
.
Provider
aria
-
label=
"操作提示"
className=
"app-feedback-region"
maxVisibleToasts=
{
3
}
placement=
"top"
width=
"min(30rem, calc(100vw - 2rem))"
>
{
({
toast
})
=>
(
<
Toast
className=
"app-feedback-toast gap-2"
toast=
{
toast
}
variant=
{
toast
.
content
.
variant
}
>
<
Toast
.
Indicator
variant=
{
toast
.
content
.
variant
}
/>
<
Toast
.
Content
className=
"min-w-0"
>
<
Toast
.
Title
>
{
toast
.
content
.
title
}
</
Toast
.
Title
>
{
toast
.
content
.
description
?
(
<
Toast
.
Description
className=
"max-h-[40dvh] overflow-y-auto whitespace-pre-wrap break-words text-foreground"
>
{
toast
.
content
.
description
}
</
Toast
.
Description
>
)
:
null
}
{
toast
.
content
.
actionProps
?
<
Toast
.
ActionButton
{
...
toast
.
content
.
actionProps
}
/>
:
null
}
</
Toast
.
Content
>
<
Button
aria
-
label=
"关闭提示"
className=
"app-feedback-close shrink-0 self-start"
isIconOnly
size=
"sm"
slot=
"close"
variant=
"ghost"
>
<
Close
size=
{
16
}
/>
</
Button
>
</
Toast
>
)
}
</
Toast
.
Provider
>
</>
);
}
src/components/AppShell.jsx
View file @
69823071
import
{
Sidebar
}
from
'@heroui-pro/react/sidebar'
;
import
{
Toast
}
from
'@heroui/react'
;
import
{
history
}
from
'umi'
;
import
DashboardNavbar
from
'./DashboardNavbar'
;
import
DashboardSidebar
from
'./DashboardSidebar'
;
...
...
@@ -32,7 +31,6 @@ export default function AppShell({
<
div
className=
"rc-app-page"
>
{
children
}
</
div
>
</
div
>
</
Sidebar
.
Main
>
<
Toast
.
Provider
maxVisibleToasts=
{
3
}
placement=
"top"
/>
</
Sidebar
.
Provider
>
);
}
src/components/DynamicFormManager.jsx
View file @
69823071
import
{
Alert
,
AlertDialog
,
Button
,
Checkbox
,
Chip
,
Input
,
Label
,
TextArea
,
TextField
}
from
'@heroui/react'
;
import
{
AlertDialog
,
Button
,
Checkbox
,
Chip
,
Input
,
Label
,
TextArea
,
TextField
}
from
'@heroui/react'
;
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
RecordWorkbench
from
'@/components/record-workbench'
;
import
{
DataTablePrimaryText
}
from
'@/components/DataTable'
;
import
FileUploadField
from
'@/components/FileUploadField'
;
...
...
@@ -46,10 +47,34 @@ const FIELD_TYPES = [
const
FIELD_TYPE_LABELS
=
Object
.
fromEntries
(
FIELD_TYPES
);
function
DeleteConfirmDialog
({
description
,
isOpen
,
onCancel
,
onConfirm
,
title
})
{
const
[
deleting
,
setDeleting
]
=
useState
(
false
);
const
[,
setError
]
=
useFeedbackState
();
useEffect
(()
=>
{
setError
(
''
);
},
[
isOpen
,
setError
]);
const
confirmDelete
=
async
()
=>
{
if
(
deleting
)
return
;
setDeleting
(
true
);
setError
(
''
);
try
{
await
onConfirm
?.();
}
catch
(
deleteError
)
{
setError
(
deleteError
?.
message
||
'删除失败,请稍后重试。'
);
}
finally
{
setDeleting
(
false
);
}
};
return
(
<
AlertDialog
.
Root
>
<
AlertDialog
.
Trigger
aria
-
hidden=
"true"
className=
"sr-only"
tabIndex=
{
-
1
}
/>
<
AlertDialog
.
Backdrop
isOpen=
{
isOpen
}
variant=
"blur"
>
<
AlertDialog
.
Backdrop
isOpen=
{
isOpen
}
isDismissable=
{
!
deleting
}
isKeyboardDismissDisabled=
{
deleting
}
onOpenChange=
{
(
open
)
=>
{
if
(
!
open
&&
!
deleting
)
onCancel
?.();
}
}
variant=
"blur"
>
<
AlertDialog
.
Container
placement=
"center"
size=
"md"
>
<
AlertDialog
.
Dialog
className=
"bg-background"
>
<
AlertDialog
.
Header
>
...
...
@@ -58,8 +83,8 @@ function DeleteConfirmDialog({ description, isOpen, onCancel, onConfirm, title }
</
AlertDialog
.
Header
>
<
AlertDialog
.
Body
>
{
description
}
</
AlertDialog
.
Body
>
<
AlertDialog
.
Footer
>
<
Button
variant=
"secondary"
onPress=
{
onCancel
}
>
取消
</
Button
>
<
Button
variant=
"danger"
onPress=
{
onConfirm
}
>
确认删除
</
Button
>
<
Button
isDisabled=
{
deleting
}
variant=
"secondary"
onPress=
{
onCancel
}
>
取消
</
Button
>
<
Button
isDisabled=
{
deleting
}
isPending=
{
deleting
}
variant=
"danger"
onPress=
{
confirmDelete
}
>
确认删除
</
Button
>
</
AlertDialog
.
Footer
>
</
AlertDialog
.
Dialog
>
</
AlertDialog
.
Container
>
...
...
@@ -71,14 +96,14 @@ function DeleteConfirmDialog({ description, isOpen, onCancel, onConfirm, title }
function
FormDialog
({
open
,
onClose
,
onSaved
,
form
})
{
const
[
title
,
setTitle
]
=
useState
(
''
);
const
[
saving
,
setSaving
]
=
useState
(
false
);
const
[
error
,
setError
]
=
useState
(
''
);
const
[
,
setError
]
=
useFeedbackState
(
);
useEffect
(()
=>
{
setError
(
''
);
if
(
open
)
{
setTitle
(
form
?.
title
||
''
);
setError
(
''
);
}
},
[
open
,
form
]);
},
[
open
,
form
,
setError
]);
const
save
=
async
()
=>
{
if
(
!
title
.
trim
())
{
...
...
@@ -123,7 +148,6 @@ function FormDialog({ open, onClose, onSaved, form }) {
<
Label
>
表单标题
</
Label
>
<
Input
value=
{
title
}
variant=
"secondary"
onChange=
{
(
event
)
=>
setTitle
(
event
.
target
.
value
)
}
/>
</
TextField
>
{
error
?
<
Alert
status=
"danger"
><
Alert
.
Content
><
Alert
.
Description
>
{
error
}
</
Alert
.
Description
></
Alert
.
Content
></
Alert
>
:
null
}
</
div
>
</
XinyuanModal
>
);
...
...
@@ -140,9 +164,10 @@ function FieldDialog({ open, onClose, onSaved, formName, field }) {
settings
:
'{}'
,
});
const
[
saving
,
setSaving
]
=
useState
(
false
);
const
[
error
,
setError
]
=
useState
(
''
);
const
[
,
setError
]
=
useFeedbackState
(
);
useEffect
(()
=>
{
setError
(
''
);
if
(
!
open
)
{
return
;
}
...
...
@@ -155,8 +180,7 @@ function FieldDialog({ open, onClose, onSaved, formName, field }) {
primary
:
Boolean
(
field
?.
primary
),
settings
:
JSON
.
stringify
(
field
?.
settings
||
{},
null
,
2
),
});
setError
(
''
);
},
[
open
,
field
]);
},
[
open
,
field
,
setError
]);
const
save
=
async
()
=>
{
if
(
!
values
.
label
.
trim
()
||
!
values
.
name
.
trim
())
{
...
...
@@ -243,7 +267,6 @@ function FieldDialog({ open, onClose, onSaved, formName, field }) {
<
Label
>
字段设置 JSON
</
Label
>
<
TextArea
fullWidth
rows=
{
9
}
variant=
"secondary"
value=
{
values
.
settings
}
onChange=
{
(
event
)
=>
setValues
((
current
)
=>
({
...
current
,
settings
:
event
.
target
.
value
}))
}
/>
</
TextField
>
{
error
?
<
Alert
className=
"sm:col-span-2"
status=
"danger"
><
Alert
.
Content
><
Alert
.
Description
>
{
error
}
</
Alert
.
Description
></
Alert
.
Content
></
Alert
>
:
null
}
</
div
>
</
XinyuanModal
>
);
...
...
@@ -252,8 +275,9 @@ function FieldDialog({ open, onClose, onSaved, formName, field }) {
function
FieldsDialog
({
form
,
open
,
onClose
})
{
const
[
formDetail
,
setFormDetail
]
=
useState
(
null
);
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
error
,
setError
]
=
useState
(
''
);
const
[
success
,
setSuccess
]
=
useState
(
''
);
const
[
loadFailed
,
setLoadFailed
]
=
useState
(
false
);
const
[,
setError
]
=
useFeedbackState
();
const
[,
setSuccess
]
=
useFeedbackState
(
'success'
);
const
[
keyword
,
setKeyword
]
=
useState
(
''
);
const
[
editingField
,
setEditingField
]
=
useState
(
null
);
const
[
fieldDialogOpen
,
setFieldDialogOpen
]
=
useState
(
false
);
...
...
@@ -263,18 +287,23 @@ function FieldsDialog({ form, open, onClose }) {
const
reload
=
useCallback
(
async
()
=>
{
if
(
!
form
?.
name
||
!
open
)
{
setError
(
''
);
setSuccess
(
''
);
return
;
}
setLoading
(
true
);
setLoadFailed
(
false
);
setError
(
''
);
try
{
setFormDetail
(
await
getDFormDetail
(
form
.
name
));
}
catch
(
requestError
)
{
setLoadFailed
(
true
);
setFormDetail
(
null
);
setError
(
requestError
?.
message
||
'字段列表加载失败。'
);
}
finally
{
setLoading
(
false
);
}
},
[
form
?.
name
,
open
]);
},
[
form
?.
name
,
open
,
setError
,
setSuccess
]);
useEffect
(()
=>
{
reload
();
...
...
@@ -403,7 +432,7 @@ function FieldsDialog({ form, open, onClose }) {
{
key
:
'delete'
,
label
:
'删除'
,
icon
:
Delete
,
tone
:
'danger'
,
onClick
:
({
record
})
=>
setDeleteField
(
record
.
raw
)
},
]
}
collapseRowActions=
{
false
}
states=
{
{
loading
,
error
,
success
,
emptyTitle
:
'暂无字段'
,
emptyDescription
:
'使用“新建字段”配置表单结构。'
}
}
states=
{
{
loading
,
loadFailed
,
onRetry
:
reload
,
emptyTitle
:
'暂无字段'
,
emptyDescription
:
'使用“新建字段”配置表单结构。'
}
}
/>
<
XinyuanModal
...
...
@@ -450,8 +479,9 @@ function FieldsDialog({ form, open, onClose }) {
export
default
function
DynamicFormManager
()
{
const
[
forms
,
setForms
]
=
useState
([]);
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
error
,
setError
]
=
useState
(
''
);
const
[
success
,
setSuccess
]
=
useState
(
''
);
const
[
loadFailed
,
setLoadFailed
]
=
useState
(
false
);
const
[,
setError
]
=
useFeedbackState
();
const
[,
setSuccess
]
=
useFeedbackState
(
'success'
);
const
[
keyword
,
setKeyword
]
=
useState
(
''
);
const
[
page
,
setPage
]
=
useState
(
0
);
const
[
pageSize
,
setPageSize
]
=
useState
(
20
);
...
...
@@ -462,17 +492,19 @@ export default function DynamicFormManager() {
const
reload
=
useCallback
(
async
()
=>
{
setLoading
(
true
);
setLoadFailed
(
false
);
setError
(
''
);
try
{
const
result
=
await
queryDForm
(
0
,
9999
,
{},
null
,
'created_at'
);
setForms
(
result
?.
forms
||
[]);
}
catch
(
requestError
)
{
setLoadFailed
(
true
);
setForms
([]);
setError
(
requestError
?.
message
||
'项目表单加载失败。'
);
}
finally
{
setLoading
(
false
);
}
},
[]);
},
[
setError
]);
useEffect
(()
=>
{
reload
();
...
...
@@ -564,7 +596,7 @@ export default function DynamicFormManager() {
setPage
(
0
);
},
}
}
states=
{
{
loading
,
error
,
success
,
emptyTitle
:
'暂无项目表单'
,
emptyDescription
:
'使用“新建表单”开始配置。'
}
}
states=
{
{
loading
,
loadFailed
,
onRetry
:
reload
,
emptyTitle
:
'暂无项目表单'
,
emptyDescription
:
'使用“新建表单”开始配置。'
}
}
/>
<
FormDialog
...
...
src/components/DynamicFormValueEditor.jsx
View file @
69823071
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
{
useEffect
,
useMemo
,
useState
}
from
'react'
;
import
{
Alert
,
AlertDialog
,
Button
,
Disclosure
,
...
...
@@ -117,7 +117,7 @@ function useFormDefinition(instance) {
const
identifier
=
getFormIdentifier
(
instance
?.
form
);
const
[
definition
,
setDefinition
]
=
useState
(
null
);
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
error
,
setError
]
=
use
State
(
''
);
const
[
error
,
setError
]
=
use
FeedbackState
(
);
useEffect
(()
=>
{
let
cancelled
=
false
;
...
...
@@ -152,7 +152,7 @@ function useFormDefinition(instance) {
return
()
=>
{
cancelled
=
true
;
};
},
[
identifier
]);
},
[
identifier
,
setError
]);
return
{
definition
,
loading
,
error
};
}
...
...
@@ -239,14 +239,14 @@ function FormDataTable({ instance, definition, loading, rowActions = null }) {
function
FormRowDialog
({
definition
,
isOpen
,
row
,
onClose
,
onSave
})
{
const
[
values
,
setValues
]
=
useState
({});
const
[
saving
,
setSaving
]
=
useState
(
false
);
const
[
error
,
setError
]
=
useState
(
''
);
const
[
,
setError
]
=
useFeedbackState
(
);
useEffect
(()
=>
{
setError
(
''
);
if
(
isOpen
)
{
setValues
(
row
&&
typeof
row
===
'object'
?
row
:
{});
setError
(
''
);
}
},
[
isOpen
,
row
]);
},
[
isOpen
,
row
,
setError
]);
const
handleSave
=
async
()
=>
{
setSaving
(
true
);
...
...
@@ -294,11 +294,6 @@ function FormRowDialog({ definition, isOpen, row, onClose, onSave }) {
}))
}
/>
))
}
{
error
?
(
<
Alert
status=
"danger"
>
<
Alert
.
Content
><
Alert
.
Description
>
{
error
}
</
Alert
.
Description
></
Alert
.
Content
>
</
Alert
>
)
:
null
}
</
div
>
</
XinyuanModal
>
);
...
...
@@ -546,7 +541,7 @@ export default function DynamicFormValueEditor({
const
instances
=
useMemo
(()
=>
normalizeInstances
(
value
,
multiple
),
[
value
,
multiple
]);
const
[
forms
,
setForms
]
=
useState
([]);
const
[
formsLoading
,
setFormsLoading
]
=
useState
(
false
);
const
[
formsError
,
setFormsError
]
=
use
State
(
''
);
const
[
formsError
,
setFormsError
]
=
use
FeedbackState
(
);
const
[
activeGroup
,
setActiveGroup
]
=
useState
(
DEFAULT_GROUP
);
const
[
newGroupName
,
setNewGroupName
]
=
useState
(
''
);
const
[
isGroupDialogOpen
,
setGroupDialogOpen
]
=
useState
(
false
);
...
...
@@ -564,7 +559,10 @@ export default function DynamicFormValueEditor({
);
useEffect
(()
=>
{
if
(
!
canEdit
)
return
undefined
;
if
(
!
canEdit
)
{
setFormsError
(
''
);
return
undefined
;
}
let
cancelled
=
false
;
setFormsLoading
(
true
);
setFormsError
(
''
);
...
...
@@ -581,7 +579,7 @@ export default function DynamicFormValueEditor({
return
()
=>
{
cancelled
=
true
;
};
},
[
canEdit
]);
},
[
canEdit
,
setFormsError
]);
const
groups
=
useMemo
(()
=>
{
const
configured
=
Array
.
from
(
new
Set
(
...
...
@@ -770,9 +768,7 @@ export default function DynamicFormValueEditor({
<
Disclosure
.
Body
>
<
div
className=
"space-y-4"
>
{
formsError
?
(
<
Alert
status=
"danger"
>
<
Alert
.
Content
><
Alert
.
Description
>
{
formsError
}
</
Alert
.
Description
></
Alert
.
Content
>
</
Alert
>
<
p
className=
"text-sm text-danger"
role=
"alert"
>
{
formsError
}
</
p
>
)
:
null
}
{
multiple
?
(
...
...
src/components/DynamicRecordDialog.jsx
View file @
69823071
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
clsx
from
'clsx'
;
import
{
Alert
,
Button
,
Checkbox
,
Input
,
Label
,
Surface
,
TextArea
,
TextField
}
from
'@heroui/react'
;
import
{
Button
,
Checkbox
,
Input
,
Label
,
Surface
,
TextArea
,
TextField
}
from
'@heroui/react'
;
import
ReferenceSearchSelect
from
'@/components/ReferenceSearchSelect'
;
import
FileUploadField
from
'@/components/FileUploadField'
;
import
FormDatePicker
from
'@/components/FormDatePicker'
;
...
...
@@ -191,7 +192,7 @@ function JsonInput({ value, onChange, placeholder }) {
function
FileInput
({
fieldDef
,
value
,
onChange
})
{
const
[
uploading
,
setUploading
]
=
useState
(
false
);
const
[
error
,
setError
]
=
useState
(
''
);
const
[
,
setError
]
=
useFeedbackState
(
);
const
handleUpload
=
async
(
files
)
=>
{
const
file
=
files
?.[
0
];
...
...
@@ -240,14 +241,14 @@ function FileInput({ fieldDef, value, onChange }) {
triggerLabel=
"选择附件"
/>
{
uploading
?
<
div
className=
"text-sm text-muted"
>
上传中...
</
div
>
:
null
}
{
error
?
<
Alert
status=
"danger"
><
Alert
.
Content
><
Alert
.
Description
>
{
error
}
</
Alert
.
Description
></
Alert
.
Content
></
Alert
>
:
null
}
</
div
>
);
}
function
NestedSubjectFields
({
fieldDef
,
value
,
onChange
,
disabled
,
depth
})
{
const
[
nestedSubject
,
setNestedSubject
]
=
useState
(
null
);
const
[
error
,
setError
]
=
useState
(
''
);
const
[
error
,
setError
]
=
useFeedbackState
();
const
[
reloadKey
,
setReloadKey
]
=
useState
(
0
);
const
subjectName
=
getReferenceSubjectName
(
fieldDef
);
useEffect
(()
=>
{
...
...
@@ -257,6 +258,8 @@ function NestedSubjectFields({ fieldDef, value, onChange, disabled, depth }) {
return
undefined
;
}
setNestedSubject
(
null
);
setError
(
''
);
loadNestedSubjectDefinition
(
subjectName
)
.
then
((
result
)
=>
{
if
(
!
cancelled
)
{
...
...
@@ -273,7 +276,7 @@ function NestedSubjectFields({ fieldDef, value, onChange, disabled, depth }) {
return
()
=>
{
cancelled
=
true
;
};
},
[
subjectName
,
depth
]);
},
[
subjectName
,
depth
,
reloadKey
,
setError
]);
if
(
!
subjectName
||
depth
>=
MAX_SUBJECT_DEPTH
)
{
const
fieldLabel
=
fieldDef
?.
label
||
fieldDef
?.
name
||
'字段'
;
...
...
@@ -287,7 +290,12 @@ function NestedSubjectFields({ fieldDef, value, onChange, disabled, depth }) {
}
if
(
error
)
{
return
<
Alert
status=
"danger"
><
Alert
.
Content
><
Alert
.
Description
>
{
error
}
</
Alert
.
Description
></
Alert
.
Content
></
Alert
>;
return
(
<
div
className=
"space-y-2"
>
<
p
className=
"text-sm text-danger"
role=
"alert"
>
{
error
}
</
p
>
<
Button
size=
"sm"
variant=
"secondary"
onPress=
{
()
=>
setReloadKey
((
current
)
=>
current
+
1
)
}
>
重新加载
</
Button
>
</
div
>
);
}
if
(
!
nestedSubject
)
{
...
...
@@ -592,7 +600,7 @@ export default function DynamicRecordDialog({
const
[
title
,
setTitle
]
=
useState
(
''
);
const
[
values
,
setValues
]
=
useState
({});
const
[
saving
,
setSaving
]
=
useState
(
false
);
const
[
error
,
setError
]
=
useState
(
''
);
const
[
,
setError
]
=
useFeedbackState
(
);
const
editing
=
Boolean
(
record
);
const
hideTitle
=
shouldHideViewTitleColumn
(
view
);
const
dialogColumns
=
useMemo
(
...
...
@@ -625,6 +633,7 @@ export default function DynamicRecordDialog({
},
[
subject
,
record
,
restrictionDefaults
]);
useEffect
(()
=>
{
setError
(
''
);
if
(
!
open
)
{
return
;
}
...
...
@@ -632,8 +641,7 @@ export default function DynamicRecordDialog({
!
editing
?
restrictionDefaults
.
title
??
''
:
''
));
setValues
(
initialValues
);
setError
(
''
);
},
[
open
,
record
,
initialValues
,
editing
,
restrictionDefaults
]);
},
[
open
,
record
,
initialValues
,
editing
,
restrictionDefaults
,
setError
]);
const
handleSubmit
=
async
()
=>
{
setSaving
(
true
);
...
...
@@ -730,9 +738,6 @@ export default function DynamicRecordDialog({
/>
);
})
}
{
error
?
(
<
Alert
className=
"md:col-span-2"
status=
"danger"
><
Alert
.
Content
><
Alert
.
Description
>
{
error
}
</
Alert
.
Description
></
Alert
.
Content
></
Alert
>
)
:
null
}
</
div
>
</
XinyuanModal
>
);
...
...
src/components/ReferenceSearchSelect.jsx
View file @
69823071
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
XinyuanAutocomplete
from
'@/components/XinyuanAutocomplete'
;
import
{
queryRecords
}
from
'@/services/DataService'
;
import
{
Button
,
Label
}
from
'@heroui/react'
;
...
...
@@ -310,7 +311,7 @@ export default function ReferenceSearchSelect({
const
[
options
,
setOptions
]
=
useState
([]);
const
[
selectedById
,
setSelectedById
]
=
useState
({});
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
error
,
setError
]
=
use
State
(
'
'
);
const
[
error
,
setError
]
=
use
FeedbackState
(
'warning
'
);
const
[
active
,
setActive
]
=
useState
(
false
);
const
lastSearchRequestId
=
useRef
(
0
);
...
...
@@ -477,7 +478,7 @@ export default function ReferenceSearchSelect({
cancelled
=
true
;
clearTimeout
(
timer
);
};
},
[
active
,
pageSize
,
query
,
queryFilter
,
searchFields
,
subject
]);
},
[
active
,
pageSize
,
query
,
queryFilter
,
searchFields
,
subject
,
setError
]);
useEffect
(()
=>
{
if
(
options
.
length
===
0
)
{
...
...
src/components/SubjectRecordWorkbench.jsx
View file @
69823071
import
{
AlertDialog
,
Button
}
from
'@heroui/react'
;
import
RecordWorkbench
from
'@/components/record-workbench'
;
import
RecordDetailDrawer
from
'@/pages/RecordDetailDrawer'
;
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
{
batchRemoveRecords
,
cloneRecord
,
...
...
@@ -128,9 +129,12 @@ export default function SubjectRecordWorkbench({
const
[
records
,
setRecords
]
=
useState
([]);
const
[
subject
,
setSubject
]
=
useState
(
null
);
const
[
views
,
setViews
]
=
useState
([]);
const
[
displayConfig
,
setDisplayConfig
]
=
useState
({});
const
[,
setDisplayConfigError
]
=
useFeedbackState
(
'warning'
);
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
error
,
setError
]
=
useState
(
''
);
const
[
success
,
setSuccess
]
=
useState
(
''
);
const
[
loadFailed
,
setLoadFailed
]
=
useState
(
false
);
const
[,
setError
]
=
useFeedbackState
();
const
[,
setSuccess
]
=
useFeedbackState
(
'success'
);
const
[
activeViewName
,
setActiveViewName
]
=
useState
(
''
);
const
[
filters
,
setFilters
]
=
useState
({});
const
[
pendingFilters
,
setPendingFilters
]
=
useState
({});
...
...
@@ -148,6 +152,8 @@ export default function SubjectRecordWorkbench({
const
requestId
=
requestSequence
.
current
+
1
;
requestSequence
.
current
=
requestId
;
setLoading
(
true
);
setLoadFailed
(
false
);
setDisplayConfigError
(
''
);
setError
(
''
);
if
(
!
keepFeedback
)
{
setSuccess
(
''
);
...
...
@@ -165,19 +171,23 @@ export default function SubjectRecordWorkbench({
setTotalRecords
(
resources
.
totalCount
||
0
);
setSubject
(
resources
.
subject
||
null
);
setViews
(
resources
.
views
||
[]);
setDisplayConfig
(
resources
.
displayConfig
||
{});
setDisplayConfigError
(
resources
.
displayConfigError
||
''
);
}
catch
(
requestError
)
{
if
(
requestId
!==
requestSequence
.
current
)
return
;
setLoadFailed
(
true
);
setRecords
([]);
setTotalRecords
(
0
);
setSubject
(
null
);
setViews
([]);
setDisplayConfig
({});
setError
(
requestError
?.
message
||
'列表加载失败,请稍后重试。'
);
}
finally
{
if
(
requestId
===
requestSequence
.
current
)
{
setLoading
(
false
);
}
}
},
[
activeViewName
,
filters
,
page
,
pageSize
,
query
,
subjectName
]);
},
[
activeViewName
,
filters
,
page
,
pageSize
,
query
,
subjectName
,
setDisplayConfigError
,
setError
,
setSuccess
]);
useEffect
(()
=>
{
reload
();
...
...
@@ -213,8 +223,8 @@ export default function SubjectRecordWorkbench({
);
const
hasAccessibleView
=
Boolean
(
activeView
);
const
configuredView
=
useMemo
(
()
=>
buildConfiguredWorkbenchView
(
records
,
{},
activeView
,
subject
),
[
records
,
activeView
,
subject
],
()
=>
buildConfiguredWorkbenchView
(
records
,
{},
activeView
,
subject
,
displayConfig
),
[
records
,
activeView
,
subject
,
displayConfig
],
);
const
filteredRecords
=
configuredView
.
filteredRecords
||
[];
const
visibleColumns
=
useMemo
(()
=>
{
...
...
@@ -248,7 +258,7 @@ export default function SubjectRecordWorkbench({
setSuccess
,
subject
,
subjectName
,
}),
[
reload
,
subject
,
subjectName
]);
}),
[
reload
,
s
etError
,
setSuccess
,
s
ubject
,
subjectName
]);
const
wrapAction
=
(
action
)
=>
({
...
action
,
...
...
@@ -423,8 +433,8 @@ export default function SubjectRecordWorkbench({
}
:
null
}
states=
{
{
loading
,
error
,
success
,
loadFailed
,
onRetry
:
reload
,
loadingTitle
:
`正在加载${title}`
,
loadingDescription
:
'正在读取对象结构、视图配置和记录数据,请稍候。'
,
emptyTitle
:
hasAccessibleView
?
'当前视图暂无数据'
:
'暂无可访问的视图'
,
...
...
src/components/TableFieldValue.jsx
0 → 100644
View file @
69823071
import
{
Chip
,
Tooltip
}
from
'@heroui/react'
;
import
{
DataTablePrimaryAction
,
DataTableText
}
from
'./DataTable'
;
import
{
getTableFieldDisplay
}
from
'@/utils/tableFieldDisplay'
;
export
default
function
TableFieldValue
({
value
,
column
,
onPress
})
{
const
display
=
getTableFieldDisplay
(
value
,
column
);
if
(
display
.
showCount
)
{
const
count
=
(
<
Chip
size=
"sm"
aria
-
label=
{
`${column.label || '关联条目'}:${display.count} 项`
}
>
{
display
.
count
}
</
Chip
>
);
const
content
=
onPress
?
(
<
DataTablePrimaryAction
text=
{
display
.
text
}
ariaLabel=
{
`${column.label || '关联条目'}:${display.count} 项,查看详情`
}
onPress=
{
onPress
}
>
{
count
}
</
DataTablePrimaryAction
>
)
:
count
;
return
display
.
labels
.
length
>
0
?
(
<
Tooltip
>
{
onPress
?
content
:
(
<
Tooltip
.
Trigger
aria
-
label=
{
`${column.label || '关联条目'}:${display.count} 项,查看标题`
}
>
{
count
}
</
Tooltip
.
Trigger
>
)
}
<
Tooltip
.
Content
>
<
div
className=
"max-h-64 max-w-80 overflow-auto whitespace-pre-wrap break-words"
>
{
display
.
labels
.
join
(
'、'
)
}
</
div
>
</
Tooltip
.
Content
>
</
Tooltip
>
)
:
content
;
}
if
(
onPress
)
{
return
(
<
Tooltip
>
<
DataTablePrimaryAction
text=
{
display
.
text
||
'-'
}
onPress=
{
onPress
}
/>
<
Tooltip
.
Content
>
{
display
.
text
||
'-'
}
</
Tooltip
.
Content
>
</
Tooltip
>
);
}
return
<
DataTableText
text=
{
display
.
text
||
'-'
}
maxLines=
{
2
}
showTooltip
/>;
}
src/components/UserSearchSelect.jsx
View file @
69823071
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
XinyuanAutocomplete
from
'@/components/XinyuanAutocomplete'
;
import
{
Label
}
from
'@heroui/react'
;
import
{
loadUser
,
queryUsers
}
from
'@/services/DataService'
;
...
...
@@ -21,11 +22,13 @@ export default function UserSearchSelect({
const
[
users
,
setUsers
]
=
useState
([]);
const
[
profiles
,
setProfiles
]
=
useState
({});
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
error
,
setError
]
=
useFeedbackState
();
const
selectedIds
=
useMemo
(()
=>
toArray
(
value
).
map
(
getUserId
).
filter
(
Boolean
),
[
value
]);
useEffect
(()
=>
{
let
cancelled
=
false
;
setLoading
(
true
);
setError
(
''
);
const
text
=
keyword
.
trim
();
queryUsers
(
0
,
30
,
text
?
{
$or
:
[
...
...
@@ -42,14 +45,17 @@ export default function UserSearchSelect({
...
Object
.
fromEntries
(
nextUsers
.
map
((
user
)
=>
[
getUserId
(
user
),
user
])),
}));
})
.
catch
(()
=>
{
if
(
!
cancelled
)
setUsers
([]);
.
catch
((
requestError
)
=>
{
if
(
!
cancelled
)
{
setUsers
([]);
setError
(
requestError
?.
message
||
'用户列表加载失败,请重新搜索。'
);
}
})
.
finally
(()
=>
{
if
(
!
cancelled
)
setLoading
(
false
);
});
return
()
=>
{
cancelled
=
true
;
};
},
[
keyword
]);
},
[
keyword
,
setError
]);
useEffect
(()
=>
{
const
missing
=
selectedIds
.
filter
((
id
)
=>
!
profiles
[
id
]);
...
...
@@ -81,6 +87,7 @@ export default function UserSearchSelect({
return
(
<
XinyuanAutocomplete
aria
-
label=
{
ariaLabel
}
errorText=
{
error
}
inputValue=
{
keyword
}
isDisabled=
{
disabled
}
isClearable
...
...
src/components/record-workbench/index.jsx
View file @
69823071
...
...
@@ -4,9 +4,11 @@ import {
DataTablePersonIdentity
,
DataTablePrimaryAction
,
DataTableText
,
EmptyStateSurface
,
isDataTableLongTextField
,
}
from
'@/components/DataTable'
;
import
{
KPI
}
from
'@heroui-pro/react/kpi'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
TableActionButton
,
TableActionGroup
,
...
...
@@ -27,9 +29,10 @@ import FilterSelect from '@/components/FilterSelect';
import
FormDatePicker
from
'@/components/FormDatePicker'
;
import
ReferenceSearchSelect
from
'@/pages/RecordDetailDrawer/components/ReferenceSearchSelect'
;
import
UserSearchSelect
from
'@/components/UserSearchSelect'
;
import
TableFieldValue
from
'@/components/TableFieldValue'
;
import
{
getTableFieldDisplayConfig
}
from
'@/utils/tableFieldDisplay'
;
import
{
formatFieldValue
,
getFieldValue
,
normalizeText
}
from
'@/utils/subjectView'
;
import
{
Alert
,
Avatar
,
Button
,
Chip
,
...
...
@@ -42,7 +45,6 @@ import {
ToggleButton
,
Tooltip
,
buttonVariants
,
toast
,
}
from
'@heroui/react'
;
import
clsx
from
'clsx'
;
import
{
Fragment
,
useEffect
,
useLayoutEffect
,
useMemo
,
useRef
,
useState
}
from
'react'
;
...
...
@@ -330,6 +332,9 @@ function getFilterPlaceholder(field) {
function
defaultRenderCell
(
record
,
column
)
{
const
value
=
getRecordFieldValue
(
record
,
column
.
field
);
if
(
getTableFieldDisplayConfig
(
column
).
isConfigured
)
{
return
<
TableFieldValue
value=
{
value
}
column=
{
column
}
/>;
}
const
text
=
formatFieldValue
(
value
,
column
.
fieldDef
)
||
'-'
;
if
(
column
.
fieldDef
?.
type
===
'user'
)
{
...
...
@@ -575,13 +580,7 @@ export default function RecordWorkbench({
const
[
expandedRowIds
,
setExpandedRowIds
]
=
useState
({});
const
filterFieldVariant
=
'secondary'
;
const
loading
=
Boolean
(
states
?.
loading
);
const
error
=
states
?.
error
||
''
;
const
totalRecords
=
pagination
?.
totalRecords
??
records
.
length
;
useEffect
(()
=>
{
if
(
states
?.
success
)
{
toast
.
success
(
states
.
success
,
{
timeout
:
4000
});
}
},
[
states
?.
success
]);
const
columnGroupMeta
=
useMemo
(
()
=>
buildColumnGroupMeta
(
columnGroups
,
columns
),
[
columnGroups
,
columns
],
...
...
@@ -922,6 +921,9 @@ export default function RecordWorkbench({
if (columnIndex === 0 && detailPanel) {
const value = getRecordFieldValue(row.record, column.field);
if (getTableFieldDisplayConfig(column).isConfigured) {
return <TableFieldValue value={value} column={column} onPress={() => row.rowContext.openDetail(row.record)} />;
}
const text = formatFieldValue(value, column.fieldDef) || '-';
return (
<DataTablePrimaryAction
...
...
@@ -1216,47 +1218,55 @@ export default function RecordWorkbench({
) : null}
</div>
{error ? (
<Alert status="danger">
<Alert.Content><Alert.Description>{error}</Alert.Description></Alert.Content>
</Alert>
) : null}
<div ref={tableScrollRef} className={tableContentClass}>
<DataTable
ariaLabel={title || '业务数据列表'}
className={stickyTable ? 'rc-data-table-sticky' : ''}
columns={dataGridColumns}
rows={dataGridRows}
isLoading={loading}
getRowKey={(row) => row.key}
selectedRowKeys={selectable ? selectedIds : undefined}
onSelectedRowKeysChange={
selectable
? (keys) =>
onSelectionChange?.(
keys.filter((key) => currentPageRecordIds.includes(key)),
)
: undefined
}
disabledRowKeys={disabledDataGridRowKeys}
emptyTitle={states?.emptyTitle || '暂无数据'}
emptyDescription={states?.emptyDescription}
renderSelectionActions={() => (
<>
<span className="text-sm">已选择记录</span>
{visibleBulkActions.map((action) => (
<BulkActionButton
key={action.key}
action={action}
context={toolbarContext}
/>
))}
</>
)}
/>
<WorkbenchPagination pagination={pagination} />
</div>
{!loading && states?.loadFailed ? (
<EmptyStateSurface>
<EmptyState className="min-h-64" size="md">
<EmptyState.Header>
<EmptyState.Title>{states.errorTitle || '数据加载失败'}</EmptyState.Title>
<EmptyState.Description>请重新加载,详细错误见操作提示。</EmptyState.Description>
</EmptyState.Header>
{states.onRetry ? (
<EmptyState.Content><Button variant="secondary" onPress={() => states.onRetry()}>重新加载</Button></EmptyState.Content>
) : null}
</EmptyState>
</EmptyStateSurface>
) : (
<div ref={tableScrollRef} className={tableContentClass}>
<DataTable
ariaLabel={title || '业务数据列表'}
className={stickyTable ? 'rc-data-table-sticky' : ''}
columns={dataGridColumns}
rows={dataGridRows}
isLoading={loading}
getRowKey={(row) => row.key}
selectedRowKeys={selectable ? selectedIds : undefined}
onSelectedRowKeysChange={
selectable
? (keys) =>
onSelectionChange?.(
keys.filter((key) => currentPageRecordIds.includes(key)),
)
: undefined
}
disabledRowKeys={disabledDataGridRowKeys}
emptyTitle={states?.emptyTitle || '暂无数据'}
emptyDescription={states?.emptyDescription}
renderSelectionActions={() => (
<>
<span className="text-sm">已选择记录</span>
{visibleBulkActions.map((action) => (
<BulkActionButton
key={action.key}
action={action}
context={toolbarContext}
/>
))}
</>
)}
/>
<WorkbenchPagination pagination={pagination} />
</div>
)}
</section>
{drawerRecordName && detailPanel?.render
...
...
src/config/application.js
View file @
69823071
...
...
@@ -2,7 +2,7 @@ export const APPLICATION_DISPLAY_NAME = '审计项目管理';
export
const
APPLICATION_NAME
=
'audit_project_manager_new'
;
export
const
APPLICATION_BASE_PATH
=
`/
${
APPLICATION_NAME
}
/`
;
export
const
AUTHORIZATION_APPLICATION_NAME
=
APPLICATION_NAME
;
export
const
SOURCE_APPLICATION_NAME
=
APPLICATION_NAME
;
export
const
SOURCE_APPLICATION_NAME
=
APPLICATION_
DISPLAY_
NAME
;
export
const
ROUTE_TITLES
=
Object
.
freeze
({
'/audit_project_table/dashboard'
:
'驾驶舱'
,
...
...
src/hooks/useFeedbackState.js
0 → 100644
View file @
69823071
import
{
toast
}
from
'@heroui/react'
;
import
{
useCallback
,
useEffect
,
useRef
,
useState
}
from
'react'
;
const
FEEDBACK_OPTIONS
=
{
error
:
{
title
:
'操作失败'
,
variant
:
'danger'
,
timeout
:
8000
},
success
:
{
title
:
'操作成功'
,
variant
:
'success'
,
timeout
:
4000
},
warning
:
{
title
:
'请注意'
,
variant
:
'warning'
,
timeout
:
8000
},
};
// Dismissing feedback must not clear request failures or the current form draft.
export
default
function
useFeedbackState
(
tone
=
'error'
)
{
const
[
message
,
setMessage
]
=
useState
(
''
);
const
toastKeyRef
=
useRef
(
null
);
const
isMountedRef
=
useRef
(
true
);
const
updateMessage
=
useCallback
((
nextMessage
)
=>
{
if
(
!
isMountedRef
.
current
)
return
;
if
(
toastKeyRef
.
current
)
toast
.
close
(
toastKeyRef
.
current
);
toastKeyRef
.
current
=
null
;
setMessage
(
nextMessage
);
if
(
!
nextMessage
)
return
;
const
{
title
,
variant
,
timeout
}
=
FEEDBACK_OPTIONS
[
tone
]
||
FEEDBACK_OPTIONS
.
error
;
// Every attempt reports feedback, even when the previous message was identical.
toastKeyRef
.
current
=
toast
(
title
,
{
description
:
nextMessage
,
variant
,
timeout
});
},
[
tone
]);
useEffect
(()
=>
{
isMountedRef
.
current
=
true
;
return
()
=>
{
isMountedRef
.
current
=
false
;
if
(
toastKeyRef
.
current
)
toast
.
close
(
toastKeyRef
.
current
);
toastKeyRef
.
current
=
null
;
};
},
[]);
return
[
message
,
updateMessage
];
}
src/pages/Login.jsx
View file @
69823071
...
...
@@ -7,7 +7,8 @@ import {
import
authService
from
'@/services/authService'
;
import
{
getCaptcha
,
userLogin
}
from
'@/services/DataService'
;
import
{
SiteContext
}
from
'@/wrapper/Site'
;
import
{
Alert
,
Button
,
Card
,
Input
,
Label
,
TextField
}
from
'@heroui/react'
;
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
{
Button
,
Card
,
Input
,
Label
,
TextField
}
from
'@heroui/react'
;
import
CryptoJS
from
'crypto-js'
;
import
{
useContext
,
useEffect
,
useState
}
from
'react'
;
import
{
history
,
useSearchParams
}
from
'umi'
;
...
...
@@ -55,7 +56,7 @@ export default function LoginPage() {
const
[
captchaData
,
setCaptchaData
]
=
useState
(
null
);
const
[
captchaLoading
,
setCaptchaLoading
]
=
useState
(
false
);
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
error
,
setError
]
=
useState
(
''
);
const
[
,
setError
]
=
useFeedbackState
(
);
const
redirect
=
normalizeRedirect
(
searchParams
.
get
(
'redirect'
));
const
refreshCaptcha
=
async
()
=>
{
...
...
@@ -205,13 +206,6 @@ export default function LoginPage() {
</
div
>
</
div
>
{
error
?
(
<
Alert
status=
"danger"
>
<
Alert
.
Content
>
<
Alert
.
Description
>
{
error
}
</
Alert
.
Description
>
</
Alert
.
Content
>
</
Alert
>
)
:
null
}
<
Button
fullWidth
...
...
src/pages/RecordDetailDrawer/components/ReferenceRecordsTable.jsx
View file @
69823071
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
{
useEffect
,
useMemo
,
useState
}
from
'react'
;
import
{
DataTable
,
...
...
@@ -168,7 +169,7 @@ export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000
const
[
subject
,
setSubject
]
=
useState
(
null
);
const
[
records
,
setRecords
]
=
useState
([]);
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
error
,
setError
]
=
use
State
(
'
'
);
const
[
error
,
setError
]
=
use
FeedbackState
(
'warning
'
);
useEffect
(()
=>
{
let
cancelled
=
false
;
...
...
@@ -234,7 +235,7 @@ export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000
return
()
=>
{
cancelled
=
true
;
};
},
[
pageSize
,
populatedRecords
,
referenceIds
,
subjectName
]);
},
[
pageSize
,
populatedRecords
,
referenceIds
,
subjectName
,
setError
]);
if
(
referenceIds
.
length
===
0
)
{
return
<
ReferenceRecordsFrame
count=
{
0
}
><
DataTableEmptyState
title=
"暂无关联记录"
/></
ReferenceRecordsFrame
>;
...
...
@@ -267,11 +268,7 @@ export default function ReferenceRecordsTable({ fieldDef, value, pageSize = 1000
return
(
<
div
className=
"space-y-2"
>
{
error
?
(
<
Alert
status=
"warning"
>
<
Alert
.
Content
>
<
Alert
.
Description
>
{
error
}
</
Alert
.
Description
>
</
Alert
.
Content
>
</
Alert
>
<
p
className=
"text-sm text-danger"
role=
"alert"
>
{
error
}
</
p
>
)
:
null
}
<
ReferenceRecordsFrame
count=
{
referenceIds
.
length
}
>
{
records
.
length
===
0
?
(
...
...
src/pages/RecordDetailDrawer/components/TailwindEditableField.jsx
View file @
69823071
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
{
useEffect
,
useMemo
,
useState
}
from
'react'
;
import
clsx
from
'clsx'
;
import
{
Alert
,
Button
,
Card
,
Checkbox
,
...
...
@@ -69,11 +69,7 @@ function FieldError({ children, className }) {
if
(
!
children
)
return
null
;
return
(
<
Alert
className=
{
className
}
status=
"danger"
>
<
Alert
.
Content
>
<
Alert
.
Description
>
{
children
}
</
Alert
.
Description
>
</
Alert
.
Content
>
</
Alert
>
<
p
className=
{
clsx
(
'text-sm text-danger'
,
className
)
}
role=
"alert"
>
{
children
}
</
p
>
);
}
...
...
@@ -178,6 +174,8 @@ function FieldValue({ value, fieldDef, plainNested = false }) {
function
UserSelector
({
value
,
onChange
,
multiple
,
placeholder
=
'请选择用户'
,
variant
})
{
const
[
keyword
,
setKeyword
]
=
useState
(
''
);
const
[
options
,
setOptions
]
=
useState
([]);
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
error
,
setError
]
=
useFeedbackState
();
const
selectedIds
=
useMemo
(
()
=>
toArray
(
value
).
map
((
item
)
=>
item
?.
_id
||
item
).
filter
(
Boolean
),
[
value
],
...
...
@@ -185,6 +183,8 @@ function UserSelector({ value, onChange, multiple, placeholder = '请选择用
useEffect
(()
=>
{
let
cancelled
=
false
;
setLoading
(
true
);
setError
(
''
);
const
loadOptions
=
async
()
=>
{
const
filter
=
keyword
.
trim
()
...
...
@@ -201,10 +201,13 @@ function UserSelector({ value, onChange, multiple, placeholder = '请选择用
if
(
!
cancelled
)
{
setOptions
(
result
?.
users
||
[]);
}
}
catch
(
e
rror
)
{
}
catch
(
requestE
rror
)
{
if
(
!
cancelled
)
{
setOptions
([]);
setError
(
requestError
?.
message
||
'用户列表加载失败,请重新搜索。'
);
}
}
finally
{
if
(
!
cancelled
)
setLoading
(
false
);
}
};
...
...
@@ -213,7 +216,7 @@ function UserSelector({ value, onChange, multiple, placeholder = '请选择用
return
()
=>
{
cancelled
=
true
;
};
},
[
keyword
]);
},
[
keyword
,
setError
]);
const
handleSelectionChange
=
(
keys
)
=>
{
if
(
!
multiple
)
{
...
...
@@ -243,8 +246,10 @@ function UserSelector({ value, onChange, multiple, placeholder = '请选择用
<
XinyuanAutocomplete
{
...
selectionProps
}
aria
-
label=
"用户"
emptyText=
"暂无用户"
emptyText=
{
loading
?
'正在加载用户...'
:
'暂无用户'
}
errorText=
{
error
}
inputValue=
{
keyword
}
isLoading=
{
loading
}
options=
{
autocompleteOptions
}
placeholder=
{
placeholder
}
searchPlaceholder=
"搜索用户名或显示名"
...
...
@@ -315,6 +320,7 @@ function ControlledFieldEditor({
useAuditMaterialCenter
,
})
{
const
[
error
,
setError
]
=
useState
(
''
);
const
[,
setRequestError
]
=
useFeedbackState
();
const
[
uploading
,
setUploading
]
=
useState
(
false
);
const
fieldType
=
fieldDef
?.
type
||
'text'
;
const
isDateField
=
isDateFieldType
(
fieldType
);
...
...
@@ -329,6 +335,7 @@ function ControlledFieldEditor({
setUploading
(
true
);
setError
(
''
);
setRequestError
(
''
);
onValidationChange
?.(
fieldPath
,
'附件正在上传,请稍候。'
,
editSessionId
);
try
{
...
...
@@ -337,6 +344,7 @@ function ControlledFieldEditor({
onChange
?.(
fieldDef
?.
multiple
?
[...
toArray
(
value
),
fileValue
]
:
fileValue
);
}
catch
(
uploadError
)
{
setError
(
uploadError
?.
message
||
'附件上传失败,请稍后重试。'
);
setRequestError
(
uploadError
?.
message
||
'附件上传失败,请稍后重试。'
);
}
finally
{
setUploading
(
false
);
onValidationChange
?.(
fieldPath
,
''
,
editSessionId
);
...
...
@@ -490,6 +498,7 @@ function ControlledFieldEditor({
if
(
!
nextValue
.
trim
())
{
setError
(
''
);
setRequestError
(
''
);
onValidationChange
?.(
fieldPath
,
''
,
editSessionId
);
return
;
}
...
...
@@ -497,6 +506,7 @@ function ControlledFieldEditor({
try
{
JSON
.
parse
(
nextValue
);
setError
(
''
);
setRequestError
(
''
);
onValidationChange
?.(
fieldPath
,
''
,
editSessionId
);
}
catch
(
parseError
)
{
const
message
=
'JSON 格式不正确,请检查后再保存。'
;
...
...
@@ -569,6 +579,7 @@ export default function TailwindEditableField({
const
[
editing
,
setEditing
]
=
useState
(
false
);
const
[
draft
,
setDraft
]
=
useState
(
value
);
const
[
error
,
setError
]
=
useState
(
''
);
const
[,
setRequestError
]
=
useFeedbackState
();
const
[
uploading
,
setUploading
]
=
useState
(
false
);
useEffect
(()
=>
{
...
...
@@ -616,6 +627,7 @@ export default function TailwindEditableField({
const
save
=
async
(
nextValue
=
draft
)
=>
{
setError
(
''
);
setRequestError
(
''
);
if
(
fieldType
===
'object'
&&
typeof
nextValue
===
'string'
&&
nextValue
.
trim
())
{
try
{
...
...
@@ -631,6 +643,7 @@ export default function TailwindEditableField({
setEditing
(
false
);
}
catch
(
saveError
)
{
setError
(
saveError
?.
message
||
'保存失败,请稍后重试。'
);
setRequestError
(
saveError
?.
message
||
'保存失败,请稍后重试。'
);
}
};
...
...
@@ -643,6 +656,7 @@ export default function TailwindEditableField({
const
cancel
=
()
=>
{
setDraft
(
value
);
setError
(
''
);
setRequestError
(
''
);
setEditing
(
false
);
};
...
...
@@ -654,6 +668,7 @@ export default function TailwindEditableField({
setUploading
(
true
);
setError
(
''
);
setRequestError
(
''
);
try
{
const
uploaded
=
await
uploadFile
(
file
);
...
...
@@ -664,6 +679,7 @@ export default function TailwindEditableField({
setEditing
(
false
);
}
catch
(
uploadError
)
{
setError
(
uploadError
?.
message
||
'附件上传失败,请稍后重试。'
);
setRequestError
(
uploadError
?.
message
||
'附件上传失败,请稍后重试。'
);
}
finally
{
setUploading
(
false
);
}
...
...
@@ -671,6 +687,7 @@ export default function TailwindEditableField({
const
handleRemoveFile
=
async
(
indexToRemove
)
=>
{
setError
(
''
);
setRequestError
(
''
);
const
nextValue
=
fieldDef
?.
multiple
?
toArray
(
draft
).
filter
((
_
,
index
)
=>
index
!==
indexToRemove
)
...
...
@@ -685,6 +702,7 @@ export default function TailwindEditableField({
}
catch
(
saveError
)
{
setDraft
(
draft
);
setError
(
saveError
?.
message
||
'删除附件失败,请稍后重试。'
);
setRequestError
(
saveError
?.
message
||
'删除附件失败,请稍后重试。'
);
}
};
...
...
src/pages/RecordDetailDrawer/hooks/useSubjectRecord.js
View file @
69823071
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
{
useEffect
,
useRef
,
useState
}
from
'react'
;
import
{
getSubjectView
,
...
...
@@ -75,7 +76,7 @@ export function useSubjectRecord(
const [subject, setSubject] = useState(null);
const [views, setViews] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = use
State(''
);
const [error, setError] = use
FeedbackState(
);
const requestIdRef = useRef(0);
useEffect(() => {
...
...
@@ -85,6 +86,7 @@ export function useSubjectRecord(
const loadAll = async () => {
if (!recordName) {
setError('');
setRemoteRecord(null);
setEditableRecord(null);
setSubject(null);
...
...
@@ -146,7 +148,7 @@ export function useSubjectRecord(
return () => {
cancelled = true;
};
}, [recordName, refreshKey, subjectName]);
}, [recordName, refreshKey, subjectName
, setError
]);
const handleFieldsUpdate = async (changes = []) => {
if (readonly || remoteRecord?.metadata?.is_locked === true || !editableRecord) {
...
...
src/pages/RecordDetailDrawer/index.jsx
View file @
69823071
import
{
Close
,
Fullscreen
,
FullscreenExit
}
from
'@/components/AppIcons'
;
import
{
Alert
,
Button
,
Modal
,
Spinner
,
Tabs
,
Tooltip
,
toast
}
from
'@heroui/react'
;
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
{
Alert
,
Button
,
Modal
,
Spinner
,
Tabs
,
Tooltip
}
from
'@heroui/react'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
React
,
{
useCallback
,
useContext
,
...
...
@@ -104,8 +106,10 @@ const RecordDetailDrawer = (props, ref) => {
const
[
draftRecord
,
setDraftRecord
]
=
useState
(
null
);
const
[
dirtyFields
,
setDirtyFields
]
=
useState
({});
const
[
validationErrors
,
setValidationErrors
]
=
useState
({});
const
[
saveError
,
setSaveError
]
=
useState
(
''
);
const
[
actionError
,
setActionError
]
=
useState
(
''
);
const
[,
setSaveError
]
=
useFeedbackState
();
const
[,
setSaveSuccess
]
=
useFeedbackState
(
'success'
);
const
[,
setSuccess
]
=
useFeedbackState
(
'success'
);
const
[,
setActionError
]
=
useFeedbackState
();
const
[
actionLoadingKey
,
setActionLoadingKey
]
=
useState
(
''
);
const
[
lastTabSwitchRevision
,
setLastTabSwitchRevision
]
=
useState
(
''
);
const
editSessionRef
=
useRef
(
0
);
...
...
@@ -129,10 +133,11 @@ const RecordDetailDrawer = (props, ref) => {
);
useEffect
(()
=>
{
setActionError
(
''
);
setSuccess
(
''
);
if
(
open
)
{
setRefreshKey
(
Date
.
now
());
setIsFullscreen
(
false
);
setActionError
(
''
);
setActionLoadingKey
(
''
);
}
...
...
@@ -142,28 +147,11 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields
({});
setValidationErrors
({});
setSaveError
(
''
);
setSaveSuccess
(
''
);
setLastTabSwitchRevision
(
''
);
pendingLocalMaterialsRef
.
current
=
{};
editSessionRef
.
current
+=
1
;
},
[
open
,
recordName
]);
useEffect
(()
=>
{
if
(
error
)
{
toast
.
danger
(
error
,
{
timeout
:
4000
});
}
},
[
error
]);
useEffect
(()
=>
{
if
(
actionError
)
{
toast
.
danger
(
actionError
,
{
timeout
:
4000
});
}
},
[
actionError
]);
useEffect
(()
=>
{
if
(
saveError
)
{
toast
.
danger
(
saveError
,
{
timeout
:
4000
});
}
},
[
saveError
]);
},
[
open
,
recordName
,
setActionError
,
setSaveError
,
setSaveSuccess
,
setSuccess
]);
useEffect
(()
=>
{
if
(
!
isEditing
)
{
...
...
@@ -313,7 +301,7 @@ const RecordDetailDrawer = (props, ref) => {
try
{
await
action
.
onClick
(
actionContext
);
if
(
action
.
successMessage
)
{
toast
.
success
(
action
.
successMessage
,
{
timeout
:
4000
}
);
setSuccess
(
action
.
successMessage
);
}
if
(
action
.
refreshAfter
)
{
refreshRecord
({
selectLastTab
:
action
.
selectLastTab
});
...
...
@@ -348,6 +336,7 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields
({});
setValidationErrors
({});
setSaveError
(
''
);
setSaveSuccess
(
''
);
pendingLocalMaterialsRef
.
current
=
{};
};
...
...
@@ -364,6 +353,7 @@ const RecordDetailDrawer = (props, ref) => {
setDirtyFields
({});
setValidationErrors
({});
setSaveError
(
''
);
setSaveSuccess
(
''
);
pendingLocalMaterialsRef
.
current
=
{};
setIsEditing
(
true
);
};
...
...
@@ -441,6 +431,7 @@ const RecordDetailDrawer = (props, ref) => {
setIsSaving
(
true
);
setSaveError
(
''
);
setSaveSuccess
(
''
);
try
{
const
pendingMaterials
=
Object
.
entries
(
pendingLocalMaterialsRef
.
current
)
...
...
@@ -469,6 +460,7 @@ const RecordDetailDrawer = (props, ref) => {
setValidationErrors
({});
pendingLocalMaterialsRef
.
current
=
{};
onDataChange
?.(
true
);
setSaveSuccess
(
'详情已保存。'
);
}
catch
(
saveFailure
)
{
setSaveError
(
saveFailure
?.
message
||
'保存失败,请稍后重试。'
);
}
finally
{
...
...
@@ -578,7 +570,17 @@ const RecordDetailDrawer = (props, ref) => {
<
div
className=
"flex min-h-48 items-center justify-center"
role=
"status"
>
<
Spinner
size=
"sm"
/>
</
div
>
)
:
error
?
null
:
(
)
:
error
?
(
<
EmptyState
className=
"min-h-48"
size=
"md"
>
<
EmptyState
.
Header
>
<
EmptyState
.
Title
>
记录详情加载失败
</
EmptyState
.
Title
>
<
EmptyState
.
Description
>
请重新加载,详细错误见操作提示。
</
EmptyState
.
Description
>
</
EmptyState
.
Header
>
<
EmptyState
.
Content
>
<
Button
variant=
"secondary"
onPress=
{
()
=>
setRefreshKey
((
current
)
=>
current
+
1
)
}
>
重新加载
</
Button
>
</
EmptyState
.
Content
>
</
EmptyState
>
)
:
(
<
div
>
{
isRecordLocked
?
(
<
Alert
status=
"warning"
>
...
...
src/pages/project_manager_xy/ApplicationSwitcherDialog.jsx
View file @
69823071
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
xinyuanLogo
from
'@/assets/xy-logo-red.png'
;
import
{
APPLICATION_BASE_PATH
,
...
...
@@ -6,12 +7,11 @@ import {
}
from
'@/config/application'
;
import
{
getApplications
}
from
'@/services/DataService'
;
import
{
EmptyStateSurface
}
from
'@/components/DataTable'
;
import
{
ExclamationTriangleIcon
,
MagnifyingGlassIcon
}
from
'@/components/AppIcons'
;
import
{
MagnifyingGlassIcon
}
from
'@/components/AppIcons'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
ItemCard
}
from
'@heroui-pro/react/item-card'
;
import
{
ItemCardGroup
}
from
'@heroui-pro/react/item-card-group'
;
import
{
Alert
,
Avatar
,
Chip
,
Modal
,
...
...
@@ -313,7 +313,7 @@ export default function ApplicationSwitcherDialog({
const
[
status
,
setStatus
]
=
useState
(
fallbackApplications
.
length
>
0
?
'success'
:
'idle'
,
);
const
[
errorMessage
,
setErrorMessage
]
=
useState
(
'
'
);
const
[
,
setErrorMessage
]
=
useFeedbackState
(
'warning
'
);
const
[
applicationSearch
,
setApplicationSearch
]
=
useState
(
''
);
const
currentApplicationName
=
useMemo
(()
=>
{
return
applications
.
find
(
isCurrentApplication
)?.
name
||
CURRENT_APPLICATION_NAME
;
...
...
@@ -351,6 +351,7 @@ export default function ApplicationSwitcherDialog({
},
[
fallbackApplications
]);
useEffect
(()
=>
{
setErrorMessage
(
''
);
if
(
!
open
)
{
setApplicationSearch
(
''
);
return
;
...
...
@@ -393,7 +394,7 @@ export default function ApplicationSwitcherDialog({
return
()
=>
{
cancelled
=
true
;
};
},
[
fallbackApplications
,
open
]);
},
[
fallbackApplications
,
open
,
setErrorMessage
]);
return
(
<
Modal
.
Root
>
...
...
@@ -444,12 +445,6 @@ export default function ApplicationSwitcherDialog({
offset=
{
12
}
size=
{
24
}
>
{
status
===
'error'
&&
errorMessage
?
(
<
Alert
className=
"mb-4"
status=
"warning"
>
<
Alert
.
Indicator
><
ExclamationTriangleIcon
size=
{
20
}
/></
Alert
.
Indicator
>
<
Alert
.
Content
><
Alert
.
Description
>
{
errorMessage
}
</
Alert
.
Description
></
Alert
.
Content
>
</
Alert
>
)
:
null
}
{
status
===
'loading'
&&
applications
.
length
===
0
?
(
<
LoadingSkeleton
/>
...
...
src/pages/project_manager_xy/AuditProjectDashboardPage.jsx
View file @
69823071
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
RecordDetailDrawer
from
'@/pages/RecordDetailDrawer'
;
import
{
EmptyStateSurface
}
from
'@/components/DataTable'
;
import
XinyuanModal
from
'@/components/XinyuanModal'
;
...
...
@@ -11,7 +12,7 @@ import {
FolderOpenIcon
,
ChartPieIcon
,
}
from
'@/components/AppIcons'
;
import
{
Alert
,
Button
,
Card
,
Chip
,
Spinner
}
from
'@heroui/react'
;
import
{
Button
,
Card
,
Chip
,
Spinner
}
from
'@heroui/react'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
KPI
}
from
'@heroui-pro/react/kpi'
;
import
{
ListView
}
from
'@heroui-pro/react/list-view'
;
...
...
@@ -299,7 +300,7 @@ function ProjectListDialog({ group, onClose, onOpenRecord }) {
export
default
function
AuditProjectDashboardPage
()
{
const
[
records
,
setRecords
]
=
useState
([]);
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
error
,
setError
]
=
use
State
(
''
);
const
[
error
,
setError
]
=
use
FeedbackState
(
);
const
[
activeYear
,
setActiveYear
]
=
useState
(
'all'
);
const
[
selectedRecordName
,
setSelectedRecordName
]
=
useState
(
''
);
const
[
projectGroup
,
setProjectGroup
]
=
useState
(
null
);
...
...
@@ -315,7 +316,7 @@ export default function AuditProjectDashboardPage() {
}
finally
{
setLoading
(
false
);
}
},
[]);
},
[
setError
]);
useEffect
(()
=>
{
loadDashboard
();
},
[
loadDashboard
]);
...
...
@@ -379,23 +380,26 @@ export default function AuditProjectDashboardPage() {
const
openRecord
=
(
record
)
=>
setSelectedRecordName
(
record
?.
name
||
record
?.
_id
||
''
);
const
openGroup
=
(
title
,
groupRecords
)
=>
setProjectGroup
({
title
,
records
:
groupRecords
});
if
(
loading
)
{
if
(
loading
&&
!
selectedRecordName
)
{
return
<
div
className=
"flex min-h-72 items-center justify-center"
><
Spinner
aria
-
label=
"正在加载审计项目驾驶舱"
/></
div
>;
}
if
(
error
)
{
if
(
error
&&
!
selectedRecordName
)
{
return
(
<
Alert
status=
"danger"
>
<
Alert
.
Content
>
<
Alert
.
Description
>
{
error
}
</
Alert
.
Description
>
<
Button
className=
"mt-3"
variant=
"secondary"
onPress=
{
loadDashboard
}
>
重新加载
</
Button
>
</
Alert
.
Content
>
</
Alert
>
<
EmptyStateSurface
>
<
EmptyState
className=
"min-h-64"
size=
"md"
>
<
EmptyState
.
Header
>
<
EmptyState
.
Title
>
驾驶舱数据加载失败
</
EmptyState
.
Title
>
<
EmptyState
.
Description
>
请重新加载,详细错误见操作提示。
</
EmptyState
.
Description
>
</
EmptyState
.
Header
>
<
EmptyState
.
Content
><
Button
variant=
"secondary"
onPress=
{
loadDashboard
}
>
重新加载
</
Button
></
EmptyState
.
Content
>
</
EmptyState
>
</
EmptyStateSurface
>
);
}
return
(
<
div
className=
"rc-page-stack"
>
<
div
aria
-
busy=
{
loading
}
className=
"rc-page-stack"
>
<
div
className=
"flex justify-end"
>
<
XinyuanAutocomplete
aria
-
label=
"统计年份"
...
...
src/pages/project_manager_xy/AuditProjectFormPage.jsx
View file @
69823071
...
...
@@ -2,6 +2,7 @@ import RecordWorkbench from '@/components/record-workbench';
import
RecordDetailDrawer
from
'@/pages/RecordDetailDrawer'
;
import
{
getSubjectViewDetail
,
loadRecord
,
loadSubject
}
from
'@/services/DataService'
;
import
{
useUserInfo
}
from
'@/hooks/user'
;
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
{
buildViewColumns
}
from
'@/utils/subjectView'
;
import
{
CheckCircleIcon
}
from
'@/components/AppIcons'
;
import
{
useCallback
,
useEffect
,
useMemo
,
useState
}
from
'react'
;
...
...
@@ -24,8 +25,9 @@ export default function AuditProjectFormPage() {
const
[
view
,
setView
]
=
useState
(
null
);
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
actionLoading
,
setActionLoading
]
=
useState
(
false
);
const
[
error
,
setError
]
=
useState
(
''
);
const
[
success
,
setSuccess
]
=
useState
(
''
);
const
[
loadFailed
,
setLoadFailed
]
=
useState
(
false
);
const
[,
setError
]
=
useFeedbackState
();
const
[,
setSuccess
]
=
useFeedbackState
(
'success'
);
const
queryParameters
=
useMemo
(()
=>
{
const
params
=
new
URLSearchParams
(
location
.
search
);
...
...
@@ -49,6 +51,10 @@ export default function AuditProjectFormPage() {
restrictionValues
,
}
=
queryParameters
;
if
(
!
subjectName
||
!
viewName
)
{
setSubject
(
null
);
setView
(
null
);
setRecords
([]);
setLoadFailed
(
true
);
setLoading
(
false
);
setError
(
'链接缺少 subject 或 view 参数,无法加载审计项目填写表。'
);
return
;
...
...
@@ -63,6 +69,7 @@ export default function AuditProjectFormPage() {
:
{};
setLoading
(
true
);
setLoadFailed
(
false
);
setError
(
''
);
try
{
const
[
subjectData
,
viewData
,
recordData
]
=
await
Promise
.
all
([
...
...
@@ -80,6 +87,7 @@ export default function AuditProjectFormPage() {
setView
(
viewData
);
setRecords
(
scopedRecords
.
map
(
normalizeWorkbenchRecord
));
}
catch
(
requestError
)
{
setLoadFailed
(
true
);
setSubject
(
null
);
setView
(
null
);
setRecords
([]);
...
...
@@ -87,7 +95,7 @@ export default function AuditProjectFormPage() {
}
finally
{
setLoading
(
false
);
}
},
[
queryParameters
]);
},
[
queryParameters
,
setError
]);
useEffect
(()
=>
{
document
.
title
=
'审计项目填写表'
;
...
...
@@ -159,8 +167,8 @@ export default function AuditProjectFormPage() {
}
}
states=
{
{
loading
,
error
,
success
,
loadFailed
,
onRetry
:
loadPage
,
loadingTitle
:
'正在加载审计项目填写表'
,
loadingDescription
:
'正在读取视图、字段结构和受限记录。'
,
emptyTitle
:
'当前链接范围暂无记录'
,
...
...
src/pages/project_manager_xy/SubjectWorkbenchService.js
View file @
69823071
...
...
@@ -14,6 +14,8 @@ import {
toArray
,
}
from
'@/utils/subjectView'
;
import
{
loadApplicationTableDisplayConfig
}
from
'@/services/tableFieldDisplayService'
;
const
QUERY_PAGE_SIZE
=
1000
;
const
DEFAULT_RECORD_SORT
=
'-updated_at -created_at'
;
const
RECORD_LEVEL_FIELDS
=
new
Set
([
...
...
@@ -276,9 +278,10 @@ export async function loadSubjectWorkbenchResources(
query
=
{},
{
viewName
=
''
,
filters
=
{},
page
=
0
,
pageSize
=
20
}
=
{},
)
{
const
[
subject
,
views
]
=
await
Promise
.
all
([
const
[
subject
,
views
,
tableDisplayConfig
]
=
await
Promise
.
all
([
loadSubject
(
subjectName
),
getSubjectView
(
subjectName
),
loadApplicationTableDisplayConfig
(
subjectName
),
]);
const
accessibleGridViews
=
buildGridViews
(
views
||
[]);
const
activeView
=
accessibleGridViews
.
find
((
view
)
=>
view
.
name
===
viewName
)
...
...
@@ -301,6 +304,7 @@ export async function loadSubjectWorkbenchResources(
totalCount
:
pageResult
.
totalCount
,
subject
,
views
:
views
||
[],
...
tableDisplayConfig
,
};
}
...
...
@@ -316,10 +320,10 @@ export function buildGridViews(views = []) {
return
isViewAccessible
(
fallbackView
)
?
[
fallbackView
]
:
[];
}
export
function
buildConfiguredWorkbenchView
(
records
,
_filters
,
view
,
subject
)
{
export
function
buildConfiguredWorkbenchView
(
records
,
_filters
,
view
,
subject
,
displayConfig
=
{}
)
{
if
(
!
view
)
return
{
columns
:
[],
filteredRecords
:
[]
};
const
columns
=
buildViewColumns
(
view
,
subject
);
const
columns
=
buildViewColumns
(
view
,
subject
,
{
displayConfig
}
);
const
filteredRecords
=
records
.
filter
((
record
)
=>
{
const
rawRecord
=
record
.
raw
||
record
;
...
...
src/pages/project_manager_xy/components/AuditProjectMaterialField.jsx
View file @
69823071
...
...
@@ -5,7 +5,8 @@ import {
queryAuditMaterials
,
uploadAuditProjectFile
,
}
from
'../auditProjectMaterialService'
;
import
{
Alert
,
Button
,
Label
,
Tabs
,
Tooltip
}
from
'@heroui/react'
;
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
{
Button
,
Label
,
Tabs
,
Tooltip
}
from
'@heroui/react'
;
import
{
useEffect
,
useMemo
,
useState
}
from
'react'
;
const
MATERIAL_CENTER_TAB
=
'material-center'
;
...
...
@@ -44,10 +45,10 @@ export default function AuditProjectMaterialField({
const
[
materials
,
setMaterials
]
=
useState
([]);
const
[
selectedMaterialId
,
setSelectedMaterialId
]
=
useState
(
null
);
const
[
materialsLoading
,
setMaterialsLoading
]
=
useState
(
false
);
const
[
materialsError
,
setMaterialsError
]
=
use
State
(
''
);
const
[
materialsError
,
setMaterialsError
]
=
use
FeedbackState
(
);
const
[
retryKey
,
setRetryKey
]
=
useState
(
0
);
const
[
uploading
,
setUploading
]
=
useState
(
false
);
const
[
uploadError
,
setUploadError
]
=
useState
(
''
);
const
[
,
setUploadError
]
=
useFeedbackState
(
);
const
[
localFileKeys
,
setLocalFileKeys
]
=
useState
(()
=>
new
Set
());
const
fileValues
=
useMemo
(
()
=>
(
multiple
?
toFileArray
(
value
)
:
toFileArray
(
value
).
slice
(
0
,
1
)),
...
...
@@ -84,7 +85,7 @@ export default function AuditProjectMaterialField({
})
.
finally
(()
=>
{
if
(
!
cancelled
)
setMaterialsLoading
(
false
);
});
return
()
=>
{
cancelled
=
true
;
};
},
[
activeTab
,
debouncedKeyword
,
retryKey
]);
},
[
activeTab
,
debouncedKeyword
,
retryKey
,
setMaterialsError
]);
const
materialOptions
=
useMemo
(()
=>
materials
.
map
((
material
)
=>
({
...
material
,
...
...
@@ -243,9 +244,10 @@ export default function AuditProjectMaterialField({
)}
/>
{materialsError ? (
<Alert status="danger"><Alert.Content><Alert.Description>{materialsError}</Alert.Description></Alert.Content>
<Button size="sm" variant="danger-soft" onPress={() => setRetryKey((key) => key + 1)}><ArrowPathIcon size={14} />重试</Button>
</Alert>
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm text-danger" role="alert">{materialsError}</p>
<Button size="sm" variant="secondary" onPress={() => setRetryKey((key) => key + 1)}><ArrowPathIcon size={14} />重试</Button>
</div>
) : null}
</div>
</Tabs.Panel>
...
...
@@ -260,7 +262,6 @@ export default function AuditProjectMaterialField({
triggerLabel={uploading ? '上传中...' : '选择本地文件'}
onFilesChange={handleLocalUpload}
/>
{uploadError ? <Alert status="danger"><Alert.Content><Alert.Description>{uploadError}</Alert.Description></Alert.Content></Alert> : null}
</div>
</Tabs.Panel>
</Tabs>
...
...
src/pages/project_manager_xy/notifications/NotificationListPage.jsx
View file @
69823071
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
{
Bell
,
Close
,
MagnifyingGlassIcon
,
OpenExternal
}
from
'@/components/AppIcons'
;
import
{
DataTablePagination
,
EmptyStateSurface
}
from
'@/components/DataTable'
;
import
{
UserContext
}
from
'@/wrapper/Auth'
;
import
{
EmptyState
}
from
'@heroui-pro/react/empty-state'
;
import
{
Alert
,
Avatar
,
Button
,
Card
,
...
...
@@ -11,7 +11,6 @@ import {
SearchField
,
Spinner
,
Tabs
,
toast
,
}
from
'@heroui/react'
;
import
{
useCallback
,
useContext
,
useEffect
,
useState
}
from
'react'
;
import
{
history
}
from
'umi'
;
...
...
@@ -130,7 +129,9 @@ export default function NotificationListPage() {
const
[
total
,
setTotal
]
=
useState
(
0
);
const
[
unreadTotal
,
setUnreadTotal
]
=
useState
(
0
);
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
error
,
setError
]
=
useState
(
''
);
const
[
error
,
setError
]
=
useFeedbackState
();
const
[,
setOperationError
]
=
useFeedbackState
();
const
[,
setSuccess
]
=
useFeedbackState
(
'success'
);
const
[
busyKey
,
setBusyKey
]
=
useState
(
''
);
const
[
reloadKey
,
setReloadKey
]
=
useState
(
0
);
...
...
@@ -162,7 +163,7 @@ export default function NotificationListPage() {
if
(
!
cancelled
)
setLoading
(
false
);
});
return
()
=>
{
cancelled
=
true
;
};
},
[
category
,
keyword
,
page
,
readStatus
,
reloadKey
]);
},
[
category
,
keyword
,
page
,
readStatus
,
reloadKey
,
setError
]);
useEffect
(()
=>
{
let
cancelled
=
false
;
...
...
@@ -175,18 +176,20 @@ export default function NotificationListPage() {
const
refresh
=
useCallback
(()
=>
setReloadKey
((
value
)
=>
value
+
1
),
[]);
const
runItemOperation
=
useCallback
(
async
(
key
,
operation
,
successMessage
)
=>
{
setBusyKey
(
key
);
setOperationError
(
''
);
setSuccess
(
''
);
try
{
await
operation
();
toast
.
success
(
successMessage
,
{
timeout
:
3000
}
);
setSuccess
(
successMessage
);
refresh
();
return
true
;
}
catch
(
operationError
)
{
toast
.
danger
(
operationError
?.
message
||
'操作失败,请稍后重试'
,
{
timeout
:
4000
}
);
setOperationError
(
operationError
?.
message
||
'操作失败,请稍后重试'
);
return
false
;
}
finally
{
setBusyKey
(
''
);
}
},
[
refresh
]);
},
[
refresh
,
setOperationError
,
setSuccess
]);
const
handleMarkRead
=
useCallback
((
notification
)
=>
runItemOperation
(
notification
.
name
,
...
...
@@ -203,6 +206,7 @@ export default function NotificationListPage() {
),
[
runItemOperation
]);
const
handleOpenTarget
=
useCallback
(
async
(
notification
,
target
)
=>
{
setBusyKey
(
notification
.
name
);
setOperationError
(
''
);
try
{
if
(
!
isNotificationRead
(
notification
,
userInfo
?.
_id
))
{
await
markNotificationRead
(
notification
.
name
);
...
...
@@ -211,11 +215,11 @@ export default function NotificationListPage() {
else
history
.
push
(
target
.
href
);
refresh
();
}
catch
(
operationError
)
{
toast
.
danger
(
operationError
?.
message
||
'无法打开通知关联流程'
,
{
timeout
:
4000
}
);
setOperationError
(
operationError
?.
message
||
'无法打开通知关联流程'
);
}
finally
{
setBusyKey
(
''
);
}
},
[
refresh
,
userInfo
?.
_id
]);
},
[
refresh
,
setOperationError
,
userInfo
?.
_id
]);
return
(
<
div
className=
"rc-page-stack"
>
...
...
@@ -253,10 +257,16 @@ export default function NotificationListPage() {
</
Tabs
>
</
div
>
{
error
?
(
<
Alert
status=
"danger"
><
Alert
.
Content
><
Alert
.
Description
>
{
error
}
</
Alert
.
Description
></
Alert
.
Content
>
<
Button
size=
"sm"
variant=
"ghost"
onPress=
{
refresh
}
>
重试
</
Button
>
</
Alert
>
{
!
loading
&&
error
?
(
<
EmptyStateSurface
>
<
EmptyState
className=
"min-h-64"
size=
"md"
>
<
EmptyState
.
Header
>
<
EmptyState
.
Title
>
通知列表加载失败
</
EmptyState
.
Title
>
<
EmptyState
.
Description
>
请重新加载通知,详细错误见操作提示。
</
EmptyState
.
Description
>
</
EmptyState
.
Header
>
<
EmptyState
.
Content
><
Button
variant=
"secondary"
onPress=
{
refresh
}
>
重试
</
Button
></
EmptyState
.
Content
>
</
EmptyState
>
</
EmptyStateSurface
>
)
:
null
}
{
loading
&&
notifications
.
length
===
0
?
(
<
div
className=
"flex min-h-64 items-center justify-center"
aria
-
label=
"通知加载中"
><
Spinner
size=
"sm"
/></
div
>
...
...
src/pages/project_manager_xy/project-management.business-flow.md
View file @
69823071
...
...
@@ -50,6 +50,48 @@
8.
列表页不展示前端写死的项目总数、完成率等摘要卡片,仅展示由服务端 Subject/View 配置驱动的视图、筛选和表格内容;项目统计统一在驾驶舱查看。
9.
业务列表、动态表单数据表和关联记录表中的长文本最多展示两行,内容始终限制在所属列内;超出内容通过 HeroUI Tooltip 或项目详情查看,避免遮挡相邻列或由单条长文本过度拉高整行。弹窗内只读关联记录表按实际数据行高度收缩,不提供无业务价值的列宽拖拽。
### 2.1 表格字段显示配置
入口为 streams-admin 的应用布局或 Subject Grid 视图配置,作用于
`/audit_project`
(
`audit_project`
)和
`/audit_project_archive`
(
`audit_project_history`
)的引用、选择、子对象及 form 列,共享 RecordWorkbench
也支持传入同样的列配置。页面加载、切换视图或刷新时读取应用和视图配置,再按当前列配置展示。
-
应用默认值来自
`audit_project_manager_new`
的
`layouts.default.subjects.<subjectName>.gridRecordConfig.displayConfig`
。
-
配置按属性逐级覆盖:应用默认值 →
`settings.displayConfig`
→ 单列
`displayConfig`
→
`settings.columns[]`
上的
`displayMode`
、
`maxTagCount`
、
`titleField`
。
-
`displayMode: "labels"`
显示已选选项或关联标题;选择字段文案来自
`settings.options`
,只展示实际选中的值。
-
`displayMode: "count"`
显示有效条目数量,忽略 null、undefined 和空白字符串;空值显示
`0`
,单值显示
`1`
。
数量标签通过悬停或键盘聚焦查看完整标题,空值不产生提示;首列仍可点击打开详情。
-
`displayMode: "auto"`
在非空条目数达到
`maxTagCount`
时显示数量,否则显示标题,判断使用
`>=`
。
只配置合法
`maxTagCount`
时自动启用 auto;显式 auto 未配置合法阈值时使用
`3`
;阈值为
`0`
时所有非空值显示数量。
阈值必须为非负整数(兼容数字字符串)。
-
子对象通过
`titleField`
读取子字段(支持嵌套路径及 metadata 下的字段);值为空时回退到已有标题、名称或 ID,
仍无标题时显示“第 N 条”。单个
`{form,data}`
表单对象按 data 行计数,表单对象数组按数组条目计数,与参考提交一致。
-
未配置合法模式或阈值时保留原单元格行为,包括表单名称和“表单数量(实例数)”摘要。
普通文本、数字、日期、布尔、附件和人员不受集合显示配置影响。
-
加载期间沿用列表加载状态;成功后应用配置。应用配置读取失败时显示警告并回退到视图配置,记录仍可加载和打开详情;
下次刷新重试,成功后清除警告。记录或 Subject/View 请求失败仍按列表原有错误流程处理。
-
只读取配置与当前字段已有数据,不额外查询关联总数,不改变查询筛选、排序、分页、状态流转和 Record metadata。
应用默认配置示例:在
`layouts.default.subjects.audit_project.gridRecordConfig.displayConfig`
设置
`{ "maxTagCount": 3 }`
;归档使用相同路径下的
`audit_project_history`
独立配置。
Grid 视图示例(配置片段,维护时保留完整原列列表):
```
json
{
"settings"
:
{
"displayConfig"
:
{
"displayMode"
:
"auto"
,
"maxTagCount"
:
3
},
"columns"
:
[
{
"field"
:
"metadata.relevant_regulations"
,
"displayMode"
:
"count"
},
{
"field"
:
"metadata.relevant_systems"
,
"displayMode"
:
"labels"
},
{
"field"
:
"metadata.project_form"
,
"displayMode"
:
"count"
}
]
}
}
```
## 3. 状态流转
```
text
...
...
@@ -141,7 +183,7 @@ Ant Design Icons。
模板标识,也不继续展示缺少定义的表格;编辑态可以重新选择有效模板,现有数据不会自动改写。
-
模板存在但缺少字段定义时显示“暂无可展示字段”,不使用已有数据对象的内部 key 临时生成
英文表头;现有行数据保持原样,待模板字段定义恢复后再按业务标签展示。
-
业务列表中的
`form`
字段在单值时只显示表单名称;多值时显示“表单数量(表单实例数)”摘要标签,不直接展示
`{ form, data, settings }`
JSON,也不展开多个表单实例或表单名称。
-
未配置表格字段显示模式或阈值时,
业务列表中的
`form`
字段在单值时只显示表单名称;多值时显示“表单数量(表单实例数)”摘要标签,不直接展示
`{ form, data, settings }`
JSON,也不展开多个表单实例或表单名称。
-
删除为不可恢复操作,执行前必须二次确认;接口失败时保留当前页面并展示错误。
## 9. 权限与限制
...
...
@@ -178,6 +220,35 @@ Ant Design Icons。
远程搜索;选择后只把
`{name,type,size,url}`
快照写入业务草稿。
-
本地文件先调用
`/upload`
并进入当前详情草稿。取消编辑不创建材料主档;单值替换,多值追加并
按 URL 去重,移除或清空业务附件不删除材料中心主档或底层文件。
-
用户点击详情“保存”后,系统才用
`source_app = "audit_project_manager_new"`
、当前用户、字段快照和
-
用户点击详情“保存”后,系统才用当前应用中文名称
`source_app = "审计项目管理"`
(统一读取
`APPLICATION_DISPLAY_NAME`
)、当前用户、字段快照和
ISO 时间逐个创建
`am_material`
。全部建档成功后才调用既有业务记录更新回调;部分失败会在当前
编辑会话保留已建档标记,重试只创建尚未成功的材料,避免重复主档。
## 12. 操作提示与异常恢复
-
登录和验证码、站点配置、切换应用、项目与归档列表、驾驶舱、外部填写表、详情保存、阶段动作、
项目表单与字段维护、引用/用户搜索、材料查询和附件上传的成功、失败及降级警告统一使用 HeroUI
Toast 浮层;接口结果不再以页面常驻横条展示。
-
提示挂载在应用根部,通过官方 Portal 显示在视口顶部,层级高于业务 Modal、删除确认和移动侧栏;
页面滚动和弹窗打开时仍可看到提示。出现提示不主动移动表单焦点。当前提示正文右侧的关闭图标
始终可见,使用 HeroUI 小号透明图标按钮,通过 Toast 的
`close`
slot 支持点击和键盘关闭;后排
堆叠提示隐藏关闭按钮,避免误关闭。按钮参与正文布局,沿用官方 Hover/焦点样式。
-
成功提示默认 4 秒消失,错误和警告默认 8 秒消失;悬停或键盘聚焦时暂停计时。同一来源再次提交
时替换上一条提示,相同错误也会重新显示;清空反馈或组件卸载时关闭该来源提示。
-
关闭或自动隐藏提示只改变展示,不清除请求失败状态、表单草稿、所选记录、字段校验错误或已建档
标记,不触发保存、删除、归档或状态流转。JSON 输入校验仍在字段附近显示文本,不随每次输入弹出提示。
-
详情弹窗点击“保存”后,只有本次待建档
`am_material`
和业务
`updateRecord`
全部成功,才退出编辑
并显示“详情已保存。”成功提示,弹窗继续展示已保存内容。任一写入失败仅显示错误并保留草稿。
重新编辑、取消编辑、再次保存、关闭弹窗或切换记录时清理上一条保存成功提示;无修改、校验失败或
正在保存时不提交、不显示成功。驾驶舱在详情数据变化后继续刷新统计,刷新期间保留已打开的详情
弹窗与提示;统计刷新失败单独显示加载错误,不清除已保存的详情。
-
列表、驾驶舱、外部填写表、项目表单/字段列表和记录详情加载失败时保留可重新加载的空状态,详细
原因由浮层提示;重试只重新读取数据。保存、删除、导入、复核或阶段动作失败时保留当前编辑/确认
界面,不将操作失败误判为整个列表加载失败;删除请求期间禁用重复确认,失败后允许再次确认。
-
应用表格显示配置读取失败时使用 warning 浮层并保留视图配置回退;下一次刷新重试。引用、人员、
子对象和材料加载错误可同时保留字段附近的错误文本或重试入口,关闭浮层不把失败当作空结果或成功。
-
通知列表加载失败时保留“重试”空状态;已读、全部已读和待办处理的操作反馈使用相同浮层规则。
关闭错误提示不修改未读数,也不伪造服务端处理成功。
-
继续使用既有 Record、Subject/View、form、notice、auth、info 和 upload 接口;
`audit_project`
、
`audit_project_history`
、
`am_material`
的字段、metadata 写入协议和状态流转不因提示展示改变。
src/services/record-api.md
View file @
69823071
...
...
@@ -44,6 +44,19 @@
-
`404`
`{"code":404,"message":"record '<name>' not found"}`
-
`400`
业务校验失败(缺少必填字段、主键冲突、类型不匹配等)
### 1.2 表格显示配置的关联读取接口
项目列表和往期归档通过已有
`DataService.loadApplication(APPLICATION_NAME)`
调用
`GET /application/:name`
,
读取
`audit_project_manager_new`
应用的
`layouts.default.subjects.<subjectName>.gridRecordConfig.displayConfig`
。
Subject 分别为
`audit_project`
、
`audit_project_history`
,路径与 streams-admin 的应用布局配置一致。
视图继续通过
`getSubjectView`
(
`GET /subject/:subjectName/view`
)读取;完整视图接口为
`getSubjectViewDetail`
(
`GET /subject/:subjectName/view/:viewName`
)。视图默认配置位于
`settings.displayConfig`
,
单列覆盖位于
`settings.columns[]`
。支持
`displayMode: labels | count | auto`
、非负整数
`maxTagCount`
和子字段路径
`titleField`
。
只读取配置,不更新应用、视图、Subject 或 Record。应用配置失败时显示警告并使用视图配置,记录查询与写入协议不变。
配置优先级、交互、错误处理及示例见
`src/pages/project_manager_xy/project-management.business-flow.md`
第 2.1 节。
## 2. Record 核心模型字段
不同
`subject`
通过
`metadata`
扩展业务字段。
...
...
src/services/tableFieldDisplayService.js
0 → 100644
View file @
69823071
import
{
APPLICATION_NAME
}
from
'@/config/application'
;
import
{
loadApplication
}
from
'@/services/DataService'
;
export
async
function
loadApplicationTableDisplayConfig
(
subjectName
)
{
try
{
const
application
=
await
loadApplication
(
APPLICATION_NAME
);
return
{
displayConfig
:
application
?.
layouts
?.
default
?.
subjects
?.[
subjectName
]?.
gridRecordConfig
?.
displayConfig
||
{},
displayConfigError
:
''
,
};
}
catch
(
error
)
{
return
{
displayConfig
:
{},
displayConfigError
:
`应用表格显示配置读取失败,暂使用视图默认显示:
${
error
?.
message
||
'请稍后重试'
}
`,
};
}
}
src/tailwind.css
View file @
69823071
...
...
@@ -31,6 +31,18 @@ html {
color-scheme
:
light
!important
;
}
/* Keep feedback above dialogs and mobile sidebars, outside page scroll containers. */
.app-feedback-region.toast-region
{
z-index
:
300
;
top
:
max
(
1rem
,
env
(
safe-area-inset-top
));
}
/* Only the front toast is interactive; its ghost close button stays visible without hover. */
.app-feedback-toast
:not
([
data-frontmost
=
"true"
])
.app-feedback-close
{
visibility
:
hidden
;
pointer-events
:
none
;
}
body
{
margin
:
0
;
font-family
:
var
(
--rc-font-sans
);
...
...
src/utils/subjectView.js
View file @
69823071
...
...
@@ -291,9 +291,10 @@ export function shouldHideViewTitleColumn(view) {
return
view
?.
settings
?.
shortTitle
===
true
;
}
export
function
buildViewColumns
(
view
,
subject
,
{
includeHidden
=
false
}
=
{})
{
export
function
buildViewColumns
(
view
,
subject
,
{
includeHidden
=
false
,
displayConfig
=
{}
}
=
{})
{
const
configuredColumns
=
view
?.
settings
?.
columns
||
[];
const
hideTitleColumn
=
!
includeHidden
&&
shouldHideViewTitleColumn
(
view
);
const
viewDisplayConfig
=
{
...
displayConfig
,
...
view
?.
settings
?.
displayConfig
};
if
(
configuredColumns
.
length
>
0
)
{
return
configuredColumns
...
...
@@ -310,6 +311,7 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {})
(
column
.
field
===
'title'
?
'标题'
:
'其他信息'
);
return
{
...
column
,
displayConfig
:
{
...
viewDisplayConfig
,
...
column
.
displayConfig
},
width
:
column
.
width
||
180
,
label
,
fieldDef
:
fieldDef
||
{
name
:
fieldName
,
label
,
type
:
'text'
},
...
...
@@ -321,6 +323,7 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {})
if
(
!
hideTitleColumn
)
{
defaultColumns
.
push
({
field
:
'title'
,
displayConfig
:
viewDisplayConfig
,
width
:
180
,
label
:
'标题'
,
fieldDef
:
{
name
:
'title'
,
label
:
'标题'
,
type
:
'text'
},
...
...
@@ -335,6 +338,7 @@ export function buildViewColumns(view, subject, { includeHidden = false } = {})
'其他信息'
;
defaultColumns
.
push
({
field
:
fieldPath
,
displayConfig
:
viewDisplayConfig
,
width
:
180
,
label
,
fieldDef
:
field
,
...
...
src/utils/tableFieldDisplay.js
0 → 100644
View file @
69823071
import
{
formatFieldValue
,
getFieldValue
,
getSelectOptions
,
isReferenceFieldType
,
normalizeRecordLabel
,
normalizeText
,
toArray
,
}
from
'./subjectView'
;
export
const
TABLE_FIELD_DISPLAY_MODES
=
[
'labels'
,
'count'
,
'auto'
];
export
const
DEFAULT_TABLE_MAX_TAG_COUNT
=
3
;
export
function
isTableFieldDisplayConfigurable
(
column
)
{
const
type
=
column
?.
fieldDef
?.
type
;
return
isReferenceFieldType
(
type
)
||
[
'select'
,
'subject'
,
'form'
].
includes
(
type
);
}
export
function
getTableFieldDisplayConfig
(
column
)
{
const
config
=
column
?.
displayConfig
||
{};
const
configuredMode
=
column
?.
displayMode
??
config
.
displayMode
;
const
configuredThreshold
=
column
?.
maxTagCount
??
config
.
maxTagCount
;
const
threshold
=
Number
(
configuredThreshold
);
const
hasThreshold
=
[
'number'
,
'string'
].
includes
(
typeof
configuredThreshold
)
&&
normalizeText
(
configuredThreshold
)
!==
''
&&
Number
.
isInteger
(
threshold
)
&&
threshold
>=
0
;
const
hasMode
=
TABLE_FIELD_DISPLAY_MODES
.
includes
(
configuredMode
);
return
{
isConfigured
:
isTableFieldDisplayConfigurable
(
column
)
&&
(
hasMode
||
hasThreshold
),
displayMode
:
hasMode
?
configuredMode
:
hasThreshold
?
'auto'
:
'labels'
,
maxTagCount
:
hasThreshold
?
threshold
:
DEFAULT_TABLE_MAX_TAG_COUNT
,
titleField
:
normalizeText
(
column
?.
titleField
??
config
.
titleField
),
};
}
function
getDisplayItems
(
value
,
fieldDef
)
{
const
items
=
fieldDef
?.
type
===
'form'
&&
value
&&
!
Array
.
isArray
(
value
)
&&
Array
.
isArray
(
value
.
data
)
?
value
.
data
:
toArray
(
value
);
return
items
.
filter
((
item
)
=>
item
!==
null
&&
item
!==
undefined
&&
!
(
typeof
item
===
'string'
&&
!
item
.
trim
()),
);
}
export
function
getTableFieldDisplay
(
value
,
column
)
{
const
config
=
getTableFieldDisplayConfig
(
column
);
const
items
=
getDisplayItems
(
value
,
column
?.
fieldDef
);
const
type
=
column
?.
fieldDef
?.
type
;
const
options
=
type
===
'select'
?
getSelectOptions
(
column
.
fieldDef
)
:
[];
const
labels
=
items
.
map
((
item
,
index
)
=>
{
if
(
type
===
'select'
)
{
const
optionValue
=
item
&&
typeof
item
===
'object'
?
item
.
value
:
item
;
const
option
=
options
.
find
((
candidate
)
=>
String
(
candidate
.
value
)
===
String
(
optionValue
));
return
normalizeText
(
option
?.
label
??
optionValue
);
}
if
([
'subject'
,
'form'
].
includes
(
type
))
{
const
titleValue
=
config
.
titleField
?
getFieldValue
(
item
,
config
.
titleField
)
??
getFieldValue
(
item
?.
metadata
,
config
.
titleField
)
:
null
;
return
formatFieldValue
(
titleValue
)
||
normalizeRecordLabel
(
item
)
||
`第
${
index
+
1
}
条`
;
}
if
(
type
===
'tag'
)
return
normalizeText
(
item
?.
label
)
||
normalizeRecordLabel
(
item
);
return
normalizeRecordLabel
(
item
);
}).
filter
(
Boolean
);
const
showCount
=
config
.
displayMode
===
'count'
||
(
config
.
displayMode
===
'auto'
&&
items
.
length
>
0
&&
items
.
length
>=
config
.
maxTagCount
);
return
{
...
config
,
count
:
items
.
length
,
labels
,
showCount
,
text
:
showCount
?
String
(
items
.
length
)
:
labels
.
join
(
'、'
),
};
}
export
function
formatTableFieldValue
(
value
,
column
)
{
const
display
=
getTableFieldDisplay
(
value
,
column
);
return
display
.
isConfigured
?
display
.
text
:
formatFieldValue
(
value
,
column
?.
fieldDef
);
}
src/wrapper/Site.jsx
View file @
69823071
import
useFeedbackState
from
'@/hooks/useFeedbackState'
;
import
{
getSystemInfo
}
from
'@/services/DataService'
;
import
{
Spinner
}
from
'@heroui/react'
;
import
dayjs
from
'dayjs'
;
...
...
@@ -19,7 +20,7 @@ export default function SiteWrapper() {
const
[
siteInfo
,
setSiteInfo
]
=
useState
(
null
);
const
[
metadata
,
setMetadata
]
=
useState
(
null
);
const
[
loading
,
setLoading
]
=
useState
(
true
);
const
[
loadError
,
setLoadError
]
=
use
State
(
''
);
const
[
loadError
,
setLoadError
]
=
use
FeedbackState
(
);
useEffect
(()
=>
{
let
cancelled
=
false
;
...
...
@@ -55,7 +56,7 @@ export default function SiteWrapper() {
return
()
=>
{
cancelled
=
true
;
};
},
[]);
},
[
setLoadError
]);
if
(
loading
)
{
return
(
...
...
tests/tableFieldDisplay.test.mjs
0 → 100644
View file @
69823071
import
assert
from
'node:assert/strict'
;
import
{
readFileSync
}
from
'node:fs'
;
import
{
createRequire
}
from
'node:module'
;
import
{
dirname
,
resolve
}
from
'node:path'
;
import
test
from
'node:test'
;
import
ts
from
'typescript'
;
const
require
=
createRequire
(
import
.
meta
.
url
);
const
sourceRoot
=
new
URL
(
'../src/'
,
import
.
meta
.
url
).
pathname
;
function
loadModule
(
entry
,
dataService
=
{})
{
const
modules
=
new
Map
();
const
load
=
(
path
)
=>
{
if
(
modules
.
has
(
path
))
return
modules
.
get
(
path
);
const
exports
=
{};
modules
.
set
(
path
,
exports
);
const
{
outputText
}
=
ts
.
transpileModule
(
readFileSync
(
path
,
'utf8'
),
{
compilerOptions
:
{
module
:
ts
.
ModuleKind
.
CommonJS
,
target
:
ts
.
ScriptTarget
.
ES2022
,
esModuleInterop
:
true
},
});
const
localRequire
=
(
specifier
)
=>
{
if
(
specifier
===
'./DataService'
||
specifier
===
'@/services/DataService'
)
return
dataService
;
if
(
!
specifier
.
startsWith
(
'.'
)
&&
!
specifier
.
startsWith
(
'@/'
))
return
require
(
specifier
);
const
dependencyPath
=
specifier
.
startsWith
(
'@/'
)
?
resolve
(
sourceRoot
,
specifier
.
slice
(
2
))
:
resolve
(
dirname
(
path
),
specifier
);
return
load
(
`
${
dependencyPath
}
.js`
);
};
new
Function
(
'require'
,
'exports'
,
outputText
)(
localRequire
,
exports
);
return
exports
;
};
return
load
(
resolve
(
sourceRoot
,
entry
));
}
const
{
getTableFieldDisplay
,
getTableFieldDisplayConfig
,
formatTableFieldValue
}
=
loadModule
(
'utils/tableFieldDisplay.js'
);
const
{
buildViewColumns
}
=
loadModule
(
'utils/subjectView.js'
);
const
column
=
(
type
,
config
=
{})
=>
({
field
:
'metadata.values'
,
fieldDef
:
{
type
},
...
config
});
test
(
'count mode counts selected values, including zero, and excludes empty entries'
,
()
=>
{
const
config
=
column
(
'select'
,
{
displayMode
:
'count'
});
assert
.
equal
(
formatTableFieldValue
([
0
,
'选中'
,
null
,
undefined
,
''
,
' '
],
config
),
'2'
);
for
(
const
value
of
[
null
,
undefined
,
''
,
[]])
assert
.
equal
(
formatTableFieldValue
(
value
,
config
),
'0'
);
assert
.
equal
(
formatTableFieldValue
(
0
,
config
),
'1'
);
assert
.
equal
(
formatTableFieldValue
({
_id
:
'one'
,
title
:
'关联标题'
},
column
(
'reference'
,
{
displayMode
:
'count'
})),
'1'
);
});
test
(
'labels show selected option labels and populated record titles'
,
()
=>
{
const
config
=
column
(
'select'
,
{
displayMode
:
'labels'
});
config
.
fieldDef
.
settings
=
{
options
:
[{
value
:
0
,
label
:
'零号'
},
{
value
:
'a'
,
label
:
'选项 A'
},
{
value
:
'b'
,
label
:
'未选项'
}]
};
assert
.
equal
(
formatTableFieldValue
([
'0'
,
'a'
],
config
),
'零号、选项 A'
);
assert
.
equal
(
formatTableFieldValue
([{
_id
:
'id-1'
,
title
:
'记录 A'
},
{
_id
:
'id-2'
,
title
:
'记录 B'
}],
column
(
'reference'
,
{
displayMode
:
'labels'
})),
'记录 A、记录 B'
);
assert
.
equal
(
formatTableFieldValue
([{
name
:
'risk'
,
label
:
'风险标签'
}],
column
(
'tag'
,
{
displayMode
:
'labels'
})),
'风险标签'
);
});
test
(
'maxTagCount alone matches streams-admin at the exact threshold'
,
()
=>
{
const
config
=
column
(
'reference'
,
{
displayConfig
:
{
maxTagCount
:
2
}
});
assert
.
equal
(
formatTableFieldValue
([{
title
:
'A'
}],
config
),
'A'
);
assert
.
equal
(
formatTableFieldValue
([{
title
:
'A'
},
{
title
:
'B'
}],
config
),
'2'
);
assert
.
equal
(
formatTableFieldValue
([],
config
),
''
);
assert
.
equal
(
formatTableFieldValue
([
'A'
],
column
(
'reference'
,
{
displayMode
:
'auto'
,
maxTagCount
:
0
})),
'1'
);
assert
.
equal
(
getTableFieldDisplayConfig
(
column
(
'reference'
,
{
displayMode
:
'auto'
})).
maxTagCount
,
3
);
});
test
(
'embedded subjects and forms use configured child labels without changing data'
,
()
=>
{
const
value
=
[{
responsible_department
:
'审计部'
},
{
metadata
:
{
responsible_department
:
'风险管理部'
}
}];
const
original
=
JSON
.
stringify
(
value
);
const
config
=
column
(
'subject'
,
{
displayMode
:
'labels'
,
titleField
:
'responsible_department'
});
assert
.
equal
(
formatTableFieldValue
(
value
,
config
),
'审计部、风险管理部'
);
assert
.
equal
(
getTableFieldDisplay
(
value
,
{
...
config
,
displayMode
:
'count'
}).
count
,
2
);
assert
.
equal
(
formatTableFieldValue
([{},
{}],
config
),
'第 1 条、第 2 条'
);
assert
.
equal
(
formatTableFieldValue
({
form
:
{
title
:
'表单'
},
data
:
value
},
column
(
'form'
,
{
displayMode
:
'count'
})),
'2'
);
assert
.
equal
(
JSON
.
stringify
(
value
),
original
);
});
test
(
'application defaults, view settings, and column overrides keep independent modes'
,
()
=>
{
const
subject
=
{
fields
:
[{
name
:
'values'
,
type
:
'select'
}]
};
const
view
=
{
settings
:
{
displayConfig
:
{
maxTagCount
:
2
},
columns
:
[{
field
:
'metadata.values'
,
displayMode
:
'labels'
}],
}
};
const
original
=
JSON
.
stringify
(
view
);
const
[
config
]
=
buildViewColumns
(
view
,
subject
,
{
displayConfig
:
{
displayMode
:
'count'
,
maxTagCount
:
5
}
});
assert
.
equal
(
getTableFieldDisplayConfig
(
config
).
maxTagCount
,
2
);
assert
.
equal
(
formatTableFieldValue
([
'A'
,
'B'
],
config
),
'A、B'
);
const
[
nestedConfig
]
=
buildViewColumns
({
settings
:
{
displayConfig
:
{
displayMode
:
'count'
,
maxTagCount
:
5
},
columns
:
[{
field
:
'metadata.values'
,
displayConfig
:
{
displayMode
:
'auto'
,
maxTagCount
:
2
}
}],
}
},
subject
);
assert
.
equal
(
formatTableFieldValue
([
'A'
,
'B'
],
nestedConfig
),
'2'
);
const
fallback
=
buildViewColumns
({
settings
:
{}
},
subject
,
{
displayConfig
:
{
displayMode
:
'count'
}
});
assert
.
equal
(
formatTableFieldValue
([
'A'
,
'B'
],
fallback
.
find
((
item
)
=>
item
.
field
===
'metadata.values'
)),
'2'
);
assert
.
equal
(
JSON
.
stringify
(
view
),
original
);
});
test
(
'missing or invalid config preserves current behavior and does not affect scalar fields'
,
()
=>
{
const
value
=
[{
responsible_department
:
'审计部'
}];
assert
.
equal
(
formatTableFieldValue
(
value
,
column
(
'subject'
)),
loadModule
(
'utils/subjectView.js'
).
formatFieldValue
(
value
,
{
type
:
'subject'
}));
assert
.
equal
(
formatTableFieldValue
(
'A'
,
column
(
'select'
,
{
displayMode
:
'invalid'
,
maxTagCount
:
-
1
})),
'A'
);
for
(
const
type
of
[
'text'
,
'number'
,
'date'
,
'boolean'
,
'user'
,
'file'
,
'object'
])
{
assert
.
equal
(
getTableFieldDisplayConfig
(
column
(
type
,
{
displayMode
:
'count'
})).
isConfigured
,
false
);
}
for
(
const
maxTagCount
of
[
'invalid'
,
-
1
,
1.5
,
null
,
''
,
' '
,
false
,
true
,
[]])
{
assert
.
equal
(
getTableFieldDisplayConfig
(
column
(
'reference'
,
{
displayConfig
:
{
maxTagCount
}
})).
isConfigured
,
false
);
}
});
test
(
'application config is read from the same layout path as streams-admin'
,
async
()
=>
{
const
requests
=
[];
const
service
=
loadModule
(
'services/tableFieldDisplayService.js'
,
{
loadApplication
:
async
(
name
)
=>
{
requests
.
push
(
name
);
return
{
layouts
:
{
default
:
{
subjects
:
{
audit_project
:
{
gridRecordConfig
:
{
displayConfig
:
{
maxTagCount
:
2
}
},
}
}
}
}
};
},
});
assert
.
deepEqual
(
await
service
.
loadApplicationTableDisplayConfig
(
'audit_project'
),
{
displayConfig
:
{
maxTagCount
:
2
},
displayConfigError
:
''
,
});
assert
.
deepEqual
(
requests
,
[
'audit_project_manager_new'
]);
assert
.
deepEqual
(
await
service
.
loadApplicationTableDisplayConfig
(
'audit_project_history'
),
{
displayConfig
:
{},
displayConfigError
:
''
,
});
});
test
(
'unavailable application config returns a warning and usable view fallback'
,
async
()
=>
{
const
service
=
loadModule
(
'services/tableFieldDisplayService.js'
,
{
loadApplication
:
async
()
=>
{
throw
new
Error
(
'无权限'
);
},
});
const
result
=
await
service
.
loadApplicationTableDisplayConfig
(
'audit_project'
);
assert
.
deepEqual
(
result
.
displayConfig
,
{});
assert
.
match
(
result
.
displayConfigError
,
/暂使用视图默认显示.*无权限/
);
});
test
(
'project and archive workbenches carry application config through columns and keep querying after config failure'
,
async
()
=>
{
const
previousWindow
=
globalThis
.
window
;
globalThis
.
window
=
{
localStorage
:
{
getItem
:
()
=>
JSON
.
stringify
({
type
:
'admin'
})
}
};
try
{
for
(
const
subjectName
of
[
'audit_project'
,
'audit_project_history'
])
{
for
(
const
configFails
of
[
false
,
true
])
{
const
requests
=
[];
const
subject
=
{
fields
:
[{
name
:
'relevant_regulations'
,
type
:
'reference'
,
multiple
:
true
}]
};
const
view
=
{
name
:
'all'
,
type
:
'grid'
,
settings
:
{
displayConfig
:
{
maxTagCount
:
2
},
columns
:
[{
field
:
'metadata.relevant_regulations'
}],
}
};
const
rawRecord
=
{
_id
:
'record-1'
,
metadata
:
{
relevant_regulations
:
[{
title
:
'法规 A'
},
{
title
:
'法规 B'
}]
}
};
const
service
=
loadModule
(
'pages/project_manager_xy/SubjectWorkbenchService.js'
,
{
loadSubject
:
async
(
name
)
=>
{
assert
.
equal
(
name
,
subjectName
);
return
subject
;
},
getSubjectView
:
async
(
name
)
=>
{
assert
.
equal
(
name
,
subjectName
);
return
[
view
];
},
loadApplication
:
async
()
=>
{
if
(
configFails
)
throw
new
Error
(
'配置暂不可用'
);
return
{
layouts
:
{
default
:
{
subjects
:
{
[
subjectName
]:
{
gridRecordConfig
:
{
displayConfig
:
{
displayMode
:
'labels'
,
maxTagCount
:
5
}
},
}
}
}
}
};
},
queryRecords
:
async
(
query
)
=>
{
requests
.
push
(
query
);
return
{
records
:
[
rawRecord
],
total_count
:
1
};
},
});
const
resources
=
await
service
.
loadSubjectWorkbenchResources
(
subjectName
,
{},
{
page
:
1
,
pageSize
:
20
});
assert
.
equal
(
requests
.
length
,
1
);
assert
.
equal
(
requests
[
0
].
subject
,
subjectName
);
assert
.
equal
(
requests
[
0
].
page
,
1
);
assert
.
equal
(
requests
[
0
].
page_size
,
20
);
assert
.
equal
(
Boolean
(
resources
.
displayConfigError
),
configFails
);
const
result
=
service
.
buildConfiguredWorkbenchView
(
resources
.
records
,
{},
view
,
subject
,
resources
.
displayConfig
);
assert
.
equal
(
result
.
filteredRecords
[
0
].
raw
,
rawRecord
);
assert
.
equal
(
getTableFieldDisplayConfig
(
result
.
columns
[
0
]).
maxTagCount
,
2
);
assert
.
equal
(
formatTableFieldValue
(
rawRecord
.
metadata
.
relevant_regulations
,
result
.
columns
[
0
]),
configFails
?
'2'
:
'法规 A、法规 B'
);
}
}
}
finally
{
if
(
previousWindow
===
undefined
)
delete
globalThis
.
window
;
else
globalThis
.
window
=
previousWindow
;
}
});
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