Skip to content
Projects
Groups
Snippets
Help
Loading...
Sign in / Register
Toggle navigation
M
model_analysis
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
model_analysis
Commits
2b4c88a1
Commit
2b4c88a1
authored
Sep 23, 2026
by
drigle
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix: support external model monitor deployment
parent
c0151ca8
Hide whitespace changes
Inline
Side-by-side
Showing
15 changed files
with
184 additions
and
52 deletions
+184
-52
.umirc.ts
.umirc.ts
+1
-1
manifest.json
public/manifest.json
+2
-2
verify-auth-links.mjs
scripts/verify-auth-links.mjs
+44
-0
Login.jsx
src/pages/Login.jsx
+3
-2
ForwardApprovals.jsx
src/pages/project_manager_xy/ForwardApprovals.jsx
+25
-1
ModelMonitorConnectorService.js
src/pages/project_manager_xy/ModelMonitorConnectorService.js
+1
-3
forwardNotificationService.js
src/pages/project_manager_xy/forwardNotificationService.js
+22
-8
model-monitor.business-flow.md
src/pages/project_manager_xy/model-monitor.business-flow.md
+22
-6
modelMonitorService.js
src/pages/project_manager_xy/modelMonitorService.js
+8
-9
notifications.business-flow.md
...t_manager_xy/notifications/notifications.business-flow.md
+2
-0
record-api.md
src/services/record-api.md
+12
-6
subjectsDefinition.md
src/services/subjectsDefinition.md
+8
-8
authRedirect.js
src/utils/authRedirect.js
+26
-0
request.js
src/utils/request.js
+6
-3
Auth.jsx
src/wrapper/Auth.jsx
+2
-3
No files found.
.umirc.ts
View file @
2b4c88a1
...
...
@@ -133,6 +133,6 @@ export default defineConfig({
define
:
{
STREAMS_API_URL
:
'/api'
,
STREAMS_PARSER_URL
:
'/parser'
,
MODEL_MONITOR_CONNECTOR_NAME
:
process
.
env
.
MODEL_MONITOR_CONNECTOR_NAME
||
'MODEL_MONITOR_ORACLE'
,
MODEL_MONITOR_CONNECTOR_NAME
:
'MODEL_MONITOR_ORACLE'
,
},
});
public/manifest.json
View file @
2b4c88a1
{
"name"
:
"model_analysis"
,
"version"
:
"1.0.
3
"
,
"version"
:
"1.0.
2
"
,
"build"
:
"abc"
,
"description"
:
"
merge master
"
,
"description"
:
"
模型分析
"
,
"date"
:
"2025-07-14 19:27:20"
,
"author"
:
"yang"
,
"spa"
:
true
...
...
scripts/verify-auth-links.mjs
0 → 100644
View file @
2b4c88a1
import
assert
from
'node:assert/strict'
;
import
{
readFile
}
from
'node:fs/promises'
;
const
source
=
async
(
path
)
=>
readFile
(
new
URL
(
`../
${
path
}
`
,
import
.
meta
.
url
),
'utf8'
);
const
moduleUrl
=
(
code
)
=>
`data:text/javascript;base64,
${
Buffer
.
from
(
code
).
toString
(
'base64'
)}
`
;
const
appUrl
=
moduleUrl
(
await
source
(
'src/constants/app.js'
));
globalThis
.
window
=
{
location
:
{
origin
:
'https://example.com:8443'
}
};
const
auth
=
await
import
(
moduleUrl
((
await
source
(
'src/utils/authRedirect.js'
)).
replace
(
"'@/constants/app'"
,
JSON
.
stringify
(
appUrl
))));
const
route
=
'/models/forwards?forwardId=a%2Fb#form'
;
for
(
const
value
of
[
route
,
`/model_analysis
${
route
}
`
,
`
${
window
.
location
.
origin
}
/model_analysis
${
route
}
`
])
{
assert
.
equal
(
auth
.
resolveLoginRedirect
(
value
),
route
);
const
login
=
new
URL
(
auth
.
getLoginUrl
(
value
),
window
.
location
.
origin
);
assert
.
equal
(
login
.
pathname
,
'/model_analysis/login'
);
assert
.
equal
(
auth
.
resolveLoginRedirect
(
login
.
searchParams
.
get
(
'redirect'
)),
route
);
}
for
(
const
value
of
[
'https://evil.example/a'
,
'//evil.example/a'
,
'javascript:alert(1)'
,
'/login?redirect=/login'
,
'/model_analysis/login'
,
'https://example.com:8443/other-app'
])
{
assert
.
equal
(
auth
.
resolveLoginRedirect
(
value
),
'/'
);
}
const
calls
=
[];
globalThis
.
__notificationCalls
=
calls
;
const
mocks
=
moduleUrl
(
`
export async function createNotice(data) { globalThis.__notificationCalls.push(data); return { name: 'notice' }; }
export async function sendNotice() {}
export async function loadUser(id) { return { _id: id, username: id }; }
export async function executeAction(name, data) { globalThis.__notificationCalls.push(data); return { taskId: 'task' }; }
`
);
const
notifications
=
await
import
(
moduleUrl
((
await
source
(
'src/pages/project_manager_xy/forwardNotificationService.js'
))
.
replace
(
"'@/services/DataService'"
,
JSON
.
stringify
(
mocks
))
.
replace
(
"'@/constants/app'"
,
JSON
.
stringify
(
appUrl
))));
for
(
const
event
of
[
'requested'
,
'first_approval_pending'
,
'final_approval_pending'
,
'first_rejected'
,
'final_rejected'
,
'final_approved'
])
{
calls
.
length
=
0
;
await
notifications
.
dispatchForwardNotification
(
event
,
{
id
:
'forward/123'
,
recipientIds
:
[
'recipient'
],
firstApprover
:
'first'
,
finalApprover
:
'final'
,
initiatorId
:
'sender'
,
oaRecipientTaskIds
:
[],
oaFinalApproverTaskId
:
[],
},
{
_id
:
'sender'
});
const
notice
=
calls
.
find
((
call
)
=>
call
.
settings
);
assert
.
equal
(
notice
.
settings
.
url
,
'/model_analysis/models/forwards?forwardId=forward%2F123'
);
assert
.
ok
(
notice
.
content
.
includes
(
`
${
window
.
location
.
origin
}${
notice
.
settings
.
url
}
`
));
for
(
const
call
of
calls
.
filter
((
call
)
=>
call
.
returnUrl
))
{
assert
.
equal
(
call
.
returnUrl
,
`
${
window
.
location
.
origin
}${
notice
.
settings
.
url
}
`
);
}
}
console
.
log
(
'Login round-trip and all six notification link scenarios passed.'
);
src/pages/Login.jsx
View file @
2b4c88a1
...
...
@@ -2,6 +2,7 @@ import xinyuanLogo from '@/assets/xy-logo-red.png';
import
xinyuanLogoWhite
from
'@/assets/xy-logo-white.png'
;
import
{
APP_DISPLAY_NAME
}
from
'@/constants/app'
;
import
authService
from
'@/services/authService'
;
import
{
resolveLoginRedirect
}
from
'@/utils/authRedirect'
;
import
{
getCaptcha
,
userLogin
}
from
'@/services/DataService'
;
import
{
SiteContext
}
from
'@/wrapper/Site'
;
import
{
Alert
,
Button
,
Card
,
Input
,
Label
,
TextField
}
from
'@heroui/react'
;
...
...
@@ -27,7 +28,7 @@ export default function LoginPage() {
const
[
captchaLoading
,
setCaptchaLoading
]
=
useState
(
false
);
const
[
loading
,
setLoading
]
=
useState
(
false
);
const
[
error
,
setError
]
=
useState
(
''
);
const
redirect
=
searchParams
.
get
(
'redirect'
)
||
'/'
;
const
redirect
=
resolveLoginRedirect
(
searchParams
.
get
(
'redirect'
))
;
const
refreshCaptcha
=
async
()
=>
{
setCaptchaLoading
(
true
);
...
...
@@ -67,7 +68,7 @@ export default function LoginPage() {
authService
.
setToken
(
loginInfo
.
token
);
}
history
.
push
(
redirect
);
history
.
replace
(
redirect
);
}
catch
(
requestError
)
{
setError
(
requestError
?.
message
||
'登录失败,请重试'
);
setCaptchaCode
(
''
);
...
...
src/pages/project_manager_xy/ForwardApprovals.jsx
View file @
2b4c88a1
...
...
@@ -31,6 +31,7 @@ import {
updateForwardRecordStatus
,
}
from
'./modelMonitorService'
;
import
{
useContext
,
useEffect
,
useMemo
,
useState
}
from
'react'
;
import
{
useSearchParams
}
from
'umi'
;
const
FILTER_OPTIONS
=
[
{
value
:
'initiated'
,
label
:
'我的发起'
},
...
...
@@ -84,6 +85,8 @@ function getForwardEditorStatusTone(record) {
export
default
function
ForwardApprovals
()
{
const
userInfo
=
useContext
(
UserContext
);
const
[
searchParams
]
=
useSearchParams
();
const
forwardId
=
searchParams
.
get
(
'forwardId'
)
||
''
;
const
currentUserId
=
userInfo
?.
_id
||
userInfo
?.
id
||
userInfo
?.
username
||
''
;
const
[
records
,
setRecords
]
=
useState
([]);
const
[
activeFilter
,
setActiveFilter
]
=
useState
(
'initiated'
);
...
...
@@ -109,6 +112,8 @@ export default function ForwardApprovals() {
(
async
()
=>
{
setLoading
(
true
);
setIsDetailDialogOpen
(
false
);
setIsEditDialogOpen
(
false
);
try
{
await
ensureModelMonitorSubjects
();
const
result
=
await
loadForwardRecords
();
...
...
@@ -121,6 +126,25 @@ export default function ForwardApprovals() {
setActiveFilter
(
'involved'
);
}
setError
(
''
);
if
(
forwardId
)
{
const
target
=
result
.
find
((
record
)
=>
record
.
id
===
forwardId
||
record
.
recordName
===
forwardId
);
const
involved
=
target
&&
(
target
.
initiatorId
===
currentUserId
||
target
.
recipientIds
.
includes
(
currentUserId
)
||
target
.
firstApprover
===
currentUserId
||
target
.
finalApprover
===
currentUserId
);
if
(
!
involved
)
{
setError
(
'链接对应的转发记录不存在,或当前账号无权查看。'
);
return
;
}
setActiveFilter
(
'involved'
);
setActiveRecordId
(
target
.
id
);
setPage
(
0
);
if
(
canRecipientEdit
(
target
,
currentUserId
))
{
await
openEditDialog
(
target
);
}
else
{
setIsDetailDialogOpen
(
true
);
}
}
}
}
catch
(
loadError
)
{
if
(
!
cancelled
)
{
...
...
@@ -136,7 +160,7 @@ export default function ForwardApprovals() {
return
()
=>
{
cancelled
=
true
;
};
},
[
currentUserId
]);
},
[
currentUserId
,
forwardId
]);
const
editableInvolvedCount
=
useMemo
(
()
=>
...
...
src/pages/project_manager_xy/ModelMonitorConnectorService.js
View file @
2b4c88a1
...
...
@@ -4,9 +4,7 @@ import {
getSource
,
}
from
'@/services/DataService'
;
const
DEFAULT_CONNECTOR_NAME
=
typeof
MODEL_MONITOR_CONNECTOR_NAME
!==
'undefined'
&&
MODEL_MONITOR_CONNECTOR_NAME
?
MODEL_MONITOR_CONNECTOR_NAME
:
'MODEL_MONITOR_ORACLE'
;
const
DEFAULT_CONNECTOR_NAME
=
'MODEL_MONITOR_ORACLE'
;
const
RESERVED_SOURCE_COLUMNS
=
new
Set
([
'MODEL_ID'
,
...
...
src/pages/project_manager_xy/forwardNotificationService.js
View file @
2b4c88a1
...
...
@@ -18,8 +18,14 @@ function uniqueIds(values = []) {
return
[...
new
Set
(
values
.
filter
(
Boolean
).
map
(
String
))];
}
function
forwardTargetUrl
()
{
return
`
${
window
.
location
.
origin
}${
FORWARD_TARGET_PATH
}
`
;
export
function
forwardTargetPath
(
record
)
{
const
recordId
=
record
?.
id
||
record
?.
recordName
;
if
(
!
recordId
)
throw
new
Error
(
'通知缺少转发记录 ID'
);
return
`
${
FORWARD_TARGET_PATH
}
?forwardId=
${
encodeURIComponent
(
recordId
)}
`
;
}
function
forwardTargetUrl
(
record
)
{
return
`
${
window
.
location
.
origin
}${
forwardTargetPath
(
record
)}
`
;
}
function
unwrapResponse
(
response
)
{
...
...
@@ -47,8 +53,8 @@ function notificationAudience(record, role) {
return
[];
}
function
contentLink
(
prefix
)
{
return
`
${
prefix
}
,请打开:
${
forwardTargetUrl
()}
`
;
function
contentLink
(
prefix
,
record
)
{
return
`
${
prefix
}
,请打开:
${
forwardTargetUrl
(
record
)}
`
;
}
async
function
sendInternalNotice
({
...
...
@@ -56,18 +62,19 @@ async function sendInternalNotice({
content
,
actor
,
audience
,
record
,
})
{
const
to
=
uniqueIds
(
audience
);
if
(
!
to
.
length
)
return
null
;
const
notice
=
unwrapResponse
(
await
createNotice
({
title
,
content
:
contentLink
(
content
),
content
:
contentLink
(
content
,
record
),
type
:
'internal'
,
category
:
'message'
,
from
:
actor
.
id
,
to
,
settings
:
{
url
:
FORWARD_TARGET_PATH
},
settings
:
{
url
:
forwardTargetPath
(
record
)
},
}));
if
(
!
notice
?.
name
)
throw
new
Error
(
'站内通知创建结果缺少 name'
);
...
...
@@ -85,6 +92,7 @@ async function createOaProgress({
title
,
actor
,
userId
,
record
,
})
{
const
receiver
=
await
loadOaUser
(
userId
);
const
result
=
await
executeAction
(
'oa_notice:createProgress'
,
{
...
...
@@ -97,7 +105,7 @@ async function createOaProgress({
sendUserId
:
actor
.
id
,
sendUserName
:
actor
.
username
||
''
,
sendDisplayName
:
actor
.
name
||
''
,
returnUrl
:
forwardTargetUrl
(),
returnUrl
:
forwardTargetUrl
(
record
),
});
const
taskId
=
unwrapResponse
(
result
)?.
taskId
;
if
(
!
taskId
)
throw
new
Error
(
`OA 待办创建结果缺少 taskId:
${
userId
}
`
);
...
...
@@ -137,7 +145,7 @@ export async function dispatchForwardNotification(event, record, user) {
name
:
user
?.
display_name
||
user
?.
displayName
||
user
?.
username
||
''
,
};
const
modelLabel
=
`
${
record
.
modelName
||
'模型'
}
/
${
record
.
batchName
||
'批次'
}
`
;
const
context
=
{
actor
};
const
context
=
{
actor
,
record
};
const
errors
=
[];
const
taskUpdates
=
{};
...
...
@@ -151,6 +159,7 @@ export async function dispatchForwardNotification(event, record, user) {
title
:
'模型监控数据填写'
,
content
:
`
${
user
?.
display_name
||
user
?.
username
||
'发起人'
}
已向您分发
$
{
modelLabel
}
的整改数据
`,
actor,
record,
audience: record.recipientIds,
}),
]);
...
...
@@ -174,6 +183,7 @@ export async function dispatchForwardNotification(event, record, user) {
title: '模型监控数据待一级审批',
content: `
$
{
modelLabel
}
已完成填写,等待您一级审批
`,
actor,
record,
audience: [record.firstApprover],
}),
]);
...
...
@@ -196,6 +206,7 @@ export async function dispatchForwardNotification(event, record, user) {
title: '模型监控数据待终审',
content: `
$
{
modelLabel
}
已通过一级审批,等待您终审
`,
actor,
record,
audience: [record.finalApprover],
}),
]);
...
...
@@ -221,6 +232,7 @@ export async function dispatchForwardNotification(event, record, user) {
title: '模型监控数据已驳回',
content: `
$
{
modelLabel
}
一级审批已驳回,请重新填写
`,
actor,
record,
audience: uniqueIds([...record.recipientIds, record.initiatorId]),
}),
]);
...
...
@@ -246,6 +258,7 @@ export async function dispatchForwardNotification(event, record, user) {
title: '模型监控数据终审驳回',
content: `
$
{
modelLabel
}
终审已驳回,请重新填写
`,
actor,
record,
audience: uniqueIds([...record.recipientIds, record.initiatorId]),
}),
]);
...
...
@@ -263,6 +276,7 @@ export async function dispatchForwardNotification(event, record, user) {
title: '模型监控流程已结束',
content: `
$
{
modelLabel
}
已通过终审
`,
actor,
record,
audience: notificationAudience(record, 'participants'),
}),
]);
...
...
src/pages/project_manager_xy/model-monitor.business-flow.md
View file @
2b4c88a1
...
...
@@ -20,7 +20,16 @@
旧
`/analysis/overview`
、
`/analysis/views`
、
`/analysis/forwards`
、
`/analysis/boards`
仅用于重定向,不再加载旧分析页面。
### 2.1 应用切换
### 2.1 通知链接与登录回跳
-
新站内通知正文、
`settings.url`
和 OA 待办
`returnUrl`
均定位
`/model_analysis/models/forwards?forwardId=<转发记录ID>`
;绝对地址使用发送时浏览器的
`window.location.origin`
(协议、域名及端口)。发送环境必须是接收人可访问的部署地址。
-
未登录打开链接时进入
`/model_analysis/login?redirect=...`
;登录过期收到 401 时同样保留路径、查询参数和 hash。登录失败留在登录页并保留目标,成功以 replace 回到原页面。
-
回跳参数统一转换成不含应用 base 的 Umi 路由,兼容同源完整应用 URL;拒绝跨域、脚本地址及登录页自身,非法参数回到首页。403 不再由请求拦截器清除登录态。
-
进入转发页后查询最新转发记录,使用
`forwardId`
匹配 ID 或 Record name,并检查当前用户是否为发起人、接收人或审批人。自动切换“我参与的”;可填写接收人打开填写表单,其他参与者打开审批详情,操作仍受现有身份和状态校验控制。
-
记录不存在或账号非参与者时显示明确错误,不自动打开其他记录;加载失败显示错误。刷新带参数的链接会重新定位,关闭弹窗后本次页面不重复自动打开。
-
历史通知不含
`forwardId`
时继续进入列表,不推断对应记录;仅打开链接不会提交或改变业务状态。
### 2.2 应用切换
-
侧栏应用切换入口打开 HeroUI 应用切换弹窗,不改变当前模型监控路由和登录状态。
-
可访问应用继续由
`GET /application/info`
与当前登录用户的
`applications`
合并得到,保留原排序、回退和跳转规则。
...
...
@@ -30,7 +39,7 @@
## 3. 外部数据源契约
生产环境的外部来源通过通用 Connector 接口读取固定 Oracle 数据源,开发联调允许使用遵循同一表结构的 MySQL 5.7+ 测试源;适配器根据 Connector 的
`settings.client`
使用 Oracle 双引号或 MySQL 反引号引用动态表与字段。同步后的模型监控数据写入
平台普通 Subject。模型监控页面不提供数据源选择器,来源 connector 名称由构建常量
`MODEL_MONITOR_CONNECTOR_NAME`
提供
。UI 仅依赖以下方法,页面调用保持不变:
生产环境的外部来源通过通用 Connector 接口读取固定 Oracle 数据源,开发联调允许使用遵循同一表结构的 MySQL 5.7+ 测试源;适配器根据 Connector 的
`settings.client`
使用 Oracle 双引号或 MySQL 反引号引用动态表与字段。同步后的模型监控数据写入
已有 Subject;对象缺失时创建绑定
`dm8`
的外部 Subject。模型监控页面不提供数据源选择器,来源 connector 别名固定为
`MODEL_MONITOR_ORACLE`
。UI 仅依赖以下方法,页面调用保持不变:
```
ts
listSourceModels
():
Promise
<
Array
<
{
...
...
@@ -60,9 +69,9 @@ loadSourceBatchData(sourceModel: SourceModel, sourceBatch: SourceBatch): Promise
`ANALYZE_MODEL_DATA_MX_*`
结果行不要求业务主键。每个批次只允许同步一次,结果行按来源读取顺序记录
`source_row_number`
并纯新增,不进行去重或更新。
MySQL 联调数据可通过
`scripts/mysql/model-monitor-source-fixtures.sql`
初始化。脚本只创建不存在的来源表,并按稳定键更新 4 个模型、16 个批次和 80 条结果样例;每个模型包含 2025 年历史批次和 2026 年当前批次,便于验证按年份分组、年份切换、跨年份勾选和全选口径。
使用测试源启动前端时,必须把
`MODEL_MONITOR_CONNECTOR_NAME`
配置为对应的 MySQL Connector;当前测试环境的 Connector 名称为
`oqJDeVZszjRM`
,可用
`MODEL_MONITOR_CONNECTOR_NAME=oqJDeVZszjRM npm run dev`
启动。该环境只验证通用同步流程,不替代 Oracle 驱动和方言的
集成验收。
MySQL 联调数据可通过
`scripts/mysql/model-monitor-source-fixtures.sql`
初始化。脚本只创建不存在的来源表,并按稳定键更新 4 个模型、16 个批次和 80 条结果样例;每个模型包含 2025 年历史批次和 2026 年当前批次,便于验证按年份分组、年份切换、跨年份勾选和全选口径。
历史测试源别名
`oqJDeVZszjRM`
不再使用;运行时来源统一为
`MODEL_MONITOR_ORACLE`
,Oracle 驱动和 DM8 外部存储仍需
集成验收。
未来若固定数据源配置变化,只替换构建常量和适配器实现,不修改页面和本地持久化服务
。
数据源别名统一固定:目标为
`dm8`
,Oracle 来源为
`MODEL_MONITOR_ORACLE`
;不再用环境变量覆盖来源别名
。
## 4. 核心流程
...
...
@@ -70,7 +79,7 @@ MySQL 联调数据可通过 `scripts/mysql/model-monitor-source-fixtures.sql`
1.
用户进入任一模型监控页面。
2.
开发阶段前端依次检查 5 个固定
`model_monitor_*`
Subject。
3.
缺失 Subject 时调用 Subject 创建接口
。
3.
仅在 Subject 查询返回 404 时创建外部对象(
`type: external`
、
`external.connector: dm8`
);认证、网络及服务端异常直接报错。已有同名对象直接复用,不限制其为内部或外部对象,也不自动转换存储类型
。
4.
初始化失败时显示明确错误,页面不得使用 localStorage 或演示 Record 冒充真实数据。
### 4.2 批量同步模型批次
...
...
@@ -87,7 +96,7 @@ MySQL 联调数据可通过 `scripts/mysql/model-monitor-source-fixtures.sql`
-
字段别名、重复字段和类型检查
-
所有数值、日期、布尔和对象字段的规范化检查
8.
本地没有模型时按
`MODEL_NAME + ID`
创建稳定
`model_monitor_model`
Record,并生成以
`model_monitor_data_`
开头的稳定动态数据 Subject 名称。
9.
自动创建或扩展普通模型数据 Subject。只允许增加字段,类型冲突立即停止
。
9.
在创建模型、批次 Record 前,先复用或扩展已有模型数据 Subject;缺失时创建绑定
`dm8`
的外部 Subject。只允许增加字段,类型冲突立即停止,不锁定批次
。
10.
按
`MODEL_RUN_BATCH_ID`
查找批次;任何状态的本地批次一旦存在都拒绝重复同步,不再读取来源结果表。
11.
为来源行生成从 1 开始的
`source_row_number`
。
12.
每 20 条分块纯新增写入,不查询已有结果行,也不执行更新;数据 Record name 由
`MODEL_RUN_BATCH_ID + source_row_number`
生成。
...
...
@@ -186,3 +195,10 @@ MySQL 联调数据可通过 `scripts/mysql/model-monitor-source-fixtures.sql`
-
批量同步模型批次弹窗使用紧凑步骤条、年份筛选与全选工具行;年份下按来源模型分段展示批次,同一模型的批次集中在同一列表区,并在分段标题展示模型名称、编码和批次数量。各分段使用 HeroUI Pro ListView 原生多选,切换年份继续保留其它年份已选项,已同步批次继续禁用,选择和同步口径不变。
-
查看态详情统一放在单一 secondary Surface;编辑态字段直接位于 Modal Overlay,字段必填、枚举来源、主键禁用和完成填写校验保持原规则。
-
Loading、错误和空状态由 HeroUI/HeroUI Pro 语义组件展示;不得生成测试记录或用伪造数值填充空页面。
### 同步审查与后续优化(2026-09-20)
-
当前流程:检查固定对象 → 加载 Oracle 模型/批次 → 拒绝已有批次 → 读取并校验来源数据 → 预检动态数据对象 → 创建模型/批次 → 每 20 行并发新增 → 统计并更新状态。
-
存量内部或外部对象均可直接复用;同步流程只在对象缺失时按
`external.connector = dm8`
创建新对象,不自动转换已有对象的存储类型。
-
后续建议:把同步迁至服务端任务,增加批次唯一约束/原子抢占、分页读取及批量写入,避免关闭页面中断和多用户竞态。
-
已知限制:写入后统计或状态保存失败可能使批次数量失真;需要持久化进度及独立对账恢复。当前一次性同步约束保留,失败批次不可直接重试。
src/pages/project_manager_xy/modelMonitorService.js
View file @
2b4c88a1
...
...
@@ -278,6 +278,8 @@ function toFieldPayload(field) {
function
toSubjectPayload
(
definition
)
{
return
{
type
:
'external'
,
external
:
{
connector
:
'dm8'
},
name
:
definition
.
name
,
title
:
definition
.
title
,
description
:
definition
.
description
||
`
${
definition
.
title
}
自动配置`
,
...
...
@@ -310,6 +312,7 @@ function normalizeSubject(subject = {}) {
name
:
candidate
?.
name
||
candidate
?.
title
||
''
,
title
:
candidate
?.
title
||
candidate
?.
name
||
''
,
type
:
candidate
?.
type
||
'normal'
,
external
:
candidate
?.
external
,
fields
:
(
Array
.
isArray
(
fields
)
?
fields
:
[]).
map
(
normalizeField
),
};
}
...
...
@@ -579,13 +582,10 @@ async function ensureSubject(definition) {
let
subject
;
try
{
subject
=
normalizeSubject
(
await
loadSubject
(
definition
.
name
));
}
catch
(
_error
)
{
return
normalizeSubject
(
await
createSubject
(
toSubjectPayload
(
definition
)));
}
const
expectedType
=
String
(
definition
.
type
||
'normal'
).
toLowerCase
();
if
(
String
(
subject
.
type
||
'normal'
).
toLowerCase
()
!==
expectedType
)
{
throw
new
Error
(
`对象“
${
definition
.
name
}
”当前类型为
${
subject
.
type
}
,期望类型为
${
expectedType
}
,已停止初始化。`
);
}
catch
(
error
)
{
const
status
=
error
?.
response
?.
status
??
error
?.
status
??
error
?.
data
?.
code
??
error
?.
code
;
if
(
Number
(
status
)
!==
404
)
throw
error
;
subject
=
normalizeSubject
(
await
createSubject
(
toSubjectPayload
(
definition
)));
}
const
existingFields
=
new
Map
(
subject
.
fields
.
map
((
field
)
=>
[
field
.
alias
,
field
]));
...
...
@@ -874,6 +874,7 @@ export async function synchronizeModelBatch(sourceModel, sourceBatch, user, onPr
onProgress
({
stage
:
'validating'
,
completed
:
0
,
total
:
normalizedRows
.
length
});
const
subjectName
=
model
?.
dataSubjectName
||
dataSubjectName
(
sourceModelKey
);
await
ensureDataSubject
(
subjectName
,
normalizedSourceModel
.
model_name
,
columns
);
try
{
if
(
model
)
{
model
=
{
...
...
@@ -927,8 +928,6 @@ export async function synchronizeModelBatch(sourceModel, sourceBatch, user, onPr
}),
createRecordName
(
'model_batch'
,
sourceBatchCode
));
await
saveModelSyncState
(
model
,
'syncing'
,
''
);
await
ensureDataSubject
(
subjectName
,
model
.
modelName
,
columns
);
let
insertedCount
=
0
;
let
failedCount
=
0
;
const
errors
=
[];
...
...
src/pages/project_manager_xy/notifications/notifications.business-flow.md
View file @
2b4c88a1
...
...
@@ -29,6 +29,8 @@
-
其他应用路径和外部 HTTP(S) 地址在新窗口打开,并使用
`noopener,noreferrer`
。
-
打开关联流程前先按现有协议标记当前通知已读;不得执行通知 HTML 或脚本。
-
模型转发新通知携带
`forwardId`
,登录回跳保留该参数,进入后按身份与最新状态自动打开填写表单或审批详情;历史无参数通知保持列表入口。详见
`../model-monitor.business-flow.md`
的“通知链接与登录回跳”。
## 异常与限制
-
所有请求继续使用现有登录 Token、请求拦截器和退出处理,不新增通知专用鉴权。
...
...
src/services/record-api.md
View file @
2b4c88a1
...
...
@@ -25,6 +25,8 @@
```
json
{
"type"
:
"external"
,
"external"
:
{
"connector"
:
"dm8"
},
"name"
:
"model_monitor_model"
,
"title"
:
"模型主档"
,
"description"
:
"模型主档自动配置"
,
...
...
@@ -44,7 +46,7 @@
}
```
开发阶段用于自动创建固定
`model_monitor_*`
Subject 和每模型动态数据 Subject。
当前均使用后端默认的
`normal`
类型,不传
`type`
和
`external
`
;对象名不附加数据库类型前缀,正式环境建议由部署流程预建固定 Subject。
开发阶段用于自动创建固定
`model_monitor_*`
Subject 和每模型动态数据 Subject。
统一传
`type: "external"`
和
`external: { "connector": "dm8" }
`
;对象名不附加数据库类型前缀,正式环境建议由部署流程预建固定 Subject。
### 2.3 增加 Subject 字段
...
...
@@ -158,7 +160,7 @@
## 5. 外部来源适配契约
当前仓库通过通用 Connector 接口读取生产 Oracle 数据源;开发联调允许接入遵循相同来源表结构的 MySQL 5.7+ 测试数据源。同步后的模型、批次和动态数据写入
平台普通 Subject。模型监控页面不直接拼 SQL,也不让用户在页面上选择数据源;来源 connector 名称由构建常量
`MODEL_MONITOR_CONNECTOR_NAME`
提供
。前端通过
`/connector/manage/:name`
读取来源连接配置,再用
`/connector/execute`
与
`/connector/inspect`
读取模型、批次和批次数据;动态标识符必须根据 Connector 客户端使用对应方言引用。接口适配层必须保持以下签名:
当前仓库通过通用 Connector 接口读取生产 Oracle 数据源;开发联调允许接入遵循相同来源表结构的 MySQL 5.7+ 测试数据源。同步后的模型、批次和动态数据写入
已有 Subject;对象缺失时创建绑定
`dm8`
的外部 Subject。模型监控页面不直接拼 SQL,也不让用户在页面上选择数据源;来源 connector 别名固定为
`MODEL_MONITOR_ORACLE`
。前端通过
`/connector/manage/:name`
读取来源连接配置,再用
`/connector/execute`
与
`/connector/inspect`
读取模型、批次和批次数据;动态标识符必须根据 Connector 客户端使用对应方言引用。接口适配层必须保持以下签名:
```
ts
listSourceModels
():
Promise
<
Array
<
{
...
...
@@ -211,7 +213,7 @@ loadSourceBatchData(sourceModel: SourceModel, sourceBatch: SourceBatch): Promise
-
`columns[].name`
必须匹配
`^[a-z][a-z0-9_]*$`
且不可重复。
-
日期在写入 Record 前转换为 ISO 8601。
-
非法字段值或字段类型冲突必须在写入前报错。
-
所有模型监控 Subject 当前均使用默认
`normal`
类型,数据写入平台内部存储;
对象名统一使用
`model_monitor_`
前缀,不附加数据库类型前缀。
-
已有模型监控 Subject 不限制为
`normal`
或
`external`
;仅在同步时发现对象缺失,才创建绑定
`external.connector = dm8`
的外部对象。
对象名统一使用
`model_monitor_`
前缀,不附加数据库类型前缀。
-
结果行只执行新增,不读取已有结果行,也不执行更新;
`source_row_number`
保存本次来源读取顺序。
-
模型、批次和数据行 Record name 分别由
`MODEL_NAME + ID`
、
`MODEL_RUN_BATCH_ID`
、
`MODEL_RUN_BATCH_ID + source_row_number`
生成稳定哈希。
...
...
@@ -297,13 +299,13 @@ Navbar 读取真实未读数;0 不显示 Badge,1-99 显示原值,100 及
```
json
{
"title"
:
"模型监控数据填写"
,
"content"
:
"接收人已收到转发,请打开:https://example.com/model_analysis/models/forwards"
,
"content"
:
"接收人已收到转发,请打开:https://example.com/model_analysis/models/forwards
?forwardId=<转发记录ID>
"
,
"type"
:
"internal"
,
"category"
:
"message"
,
"from"
:
"发起人 _id"
,
"to"
:
[
"接收人 _id"
],
"settings"
:
{
"url"
:
"/model_analysis/models/forwards"
"url"
:
"/model_analysis/models/forwards
?forwardId=<转发记录ID>
"
}
}
```
...
...
@@ -329,7 +331,7 @@ Navbar 读取真实未读数;0 不显示 Badge,1-99 显示原值,100 及
"sendUserId"
:
"发起人 _id"
,
"sendUserName"
:
"发起人账号"
,
"sendDisplayName"
:
"发起人显示名"
,
"returnUrl"
:
"https://example.com/model_analysis/models/forwards"
"returnUrl"
:
"https://example.com/model_analysis/models/forwards
?forwardId=<转发记录ID>
"
}
```
...
...
@@ -344,3 +346,7 @@ Navbar 读取真实未读数;0 不显示 Badge,1-99 显示原值,100 及
"taskId"
:
"待关闭的 OA 任务 ID"
}
```
## 外部对象初始化限制
外部创建协议已核对后端
`SubjectManager.createSubject`
:后端生成物理表名
`dm_<Subject name>`
,业务 Subject name 保持不变。前端仅对 GET Subject 的 404 创建绑定
`dm8`
的外部对象,不把认证或网络错误当作对象缺失;已有内部或外部对象均直接复用,不检查或转换其存储类型。新增字段仍调用
`POST /subject/:name/field`
,由后端按对象实际类型处理字段结构变更。
src/services/subjectsDefinition.md
View file @
2b4c88a1
...
...
@@ -15,7 +15,7 @@
旧
`analysis_view_config`
、
`analysis_view_group`
、
`analysis_forward_record`
、
`analysis_forward_record_item`
、
`analysis_dashboard_board`
仅作为历史数据保留,新界面不得读取或写入。
当前阶段所有模型监控 Subject 均使用默认
`normal`
类型,Record 保存在平台内部存储中;MySQL 仅作为模型来源数据源
。对象名统一使用
`model_monitor_`
前缀,不附加数据库类型前缀。
已有模型监控 Subject 可以是
`normal`
或
`external`
,同步流程不限制或转换存量对象类型;对象缺失时新建绑定
`dm8`
的外部对象。Oracle 来源别名固定为
`MODEL_MONITOR_ORACLE`
。对象名统一使用
`model_monitor_`
前缀,不附加数据库类型前缀。
## 1. 统一 Record 协议
...
...
@@ -38,7 +38,7 @@ type Record = {
## 动态模型数据 Subject
每个模型首次同步时按
`ANALYZE_MXVIEW_INFO.MODEL_NAME + ID`
自动创建稳定的
`model_monitor_data_<stable_id>`
普通
动态数据 Subject,后续批次继续使用同一 Subject。
每个模型首次同步时按
`ANALYZE_MXVIEW_INFO.MODEL_NAME + ID`
自动创建稳定的
`model_monitor_data_<stable_id>`
外部
动态数据 Subject,后续批次继续使用同一 Subject。
固定系统字段:
...
...
@@ -61,7 +61,7 @@ type Record = {
-
模型和批次不提供删除。
-
模型仅由同步自动创建,来源模型 ID、模型编码和名称不可本地修改。
-
`hidden_record_ids`
为批次级共享状态,与转发的
`excluded_record_ids`
分开维护。
-
固定 Subject 在开发阶段由前端检查
并创建为普通对象;正式环境应
由部署流程预建。
-
固定 Subject 在开发阶段由前端检查
;已有内部或外部对象均直接复用,缺失时创建绑定
`dm8`
的外部对象。正式环境建议
由部署流程预建。
-
已存在的 Subject 自动补充新字段时先按可选字段添加,以兼容历史 Record;新同步数据仍由 service 层执行必填校验。
-
Subject/Record 写入失败必须明确报错,不得回退 localStorage。
...
...
@@ -74,7 +74,7 @@ type Record = {
### `model_monitor_group`:模型分组
-
类型:
`normal`
-
类型:
不限制(缺失时创建
`external.connector = dm8`
)
-
字段数量:4
-
说明:模型分组自动配置
...
...
@@ -87,7 +87,7 @@ type Record = {
### `model_monitor_model`:模型主档
-
类型:
`normal`
-
类型:
不限制(缺失时创建
`external.connector = dm8`
)
-
字段数量:19
-
说明:模型主档自动配置
...
...
@@ -115,7 +115,7 @@ type Record = {
### `model_monitor_batch`:模型批次
-
类型:
`normal`
-
类型:
不限制(缺失时创建
`external.connector = dm8`
)
-
字段数量:25
-
说明:模型批次自动配置
...
...
@@ -149,7 +149,7 @@ type Record = {
### `model_monitor_forward_record`:模型转发审批记录
-
类型:
`normal`
-
类型:
不限制(缺失时创建
`external.connector = dm8`
)
-
字段数量:26
-
说明:模型转发审批记录自动配置
...
...
@@ -184,7 +184,7 @@ type Record = {
### `model_monitor_forward_item`:模型转发回填明细
-
类型:
`normal`
-
类型:
不限制(缺失时创建
`external.connector = dm8`
)
-
字段数量:16
-
说明:模型转发回填明细自动配置
...
...
src/utils/authRedirect.js
0 → 100644
View file @
2b4c88a1
import
{
APP_BASE_PATH
,
getAppUrl
,
stripAppBase
}
from
'@/constants/app'
;
// Umi navigation uses routes without the deployment base; browser navigation includes it.
export
function
resolveLoginRedirect
(
value
,
origin
=
window
.
location
.
origin
)
{
if
(
!
value
||
typeof
value
!==
'string'
)
return
'/'
;
try
{
if
(
!
value
.
startsWith
(
'/'
)
&&
!
/^https
?
:
\/\/
/i
.
test
(
value
))
return
'/'
;
const
url
=
new
URL
(
value
,
origin
);
if
(
url
.
origin
!==
origin
)
return
'/'
;
if
(
/^https
?
:/i
.
test
(
value
)
&&
url
.
pathname
!==
APP_BASE_PATH
&&
!
url
.
pathname
.
startsWith
(
`
${
APP_BASE_PATH
}
/`
))
return
'/'
;
const
pathname
=
stripAppBase
(
url
.
pathname
);
if
(
pathname
===
'/login'
||
pathname
.
startsWith
(
'/login/'
))
return
'/'
;
return
`
${
pathname
}${
url
.
search
}${
url
.
hash
}
`
;
}
catch
{
return
'/'
;
}
}
export
function
getLoginRoute
(
target
)
{
return
`/login?redirect=
${
encodeURIComponent
(
resolveLoginRedirect
(
target
))}
`
;
}
export
function
getLoginUrl
(
target
)
{
return
getAppUrl
(
getLoginRoute
(
target
));
}
src/utils/request.js
View file @
2b4c88a1
import
{
extend
}
from
'umi-request'
;
import
progressMiddleware
from
'umi-request-progress'
;
import
authService
from
'@/services/authService'
;
import
{
stripAppBase
}
from
'@/constants/app'
;
import
{
getLoginUrl
}
from
'@/utils/authRedirect'
;
// 使用 .umirc.ts 中定义的全局变量
const
baseUrl
=
STREAMS_API_URL
;
...
...
@@ -39,10 +41,11 @@ function createRequestWithPrefix(prefix = baseUrl) {
console
.
warn
(
'[request] authentication required (401)'
);
// 跳转到登录页(可选,根据业务需求调整)
location
.
href
=
'/login?redirect='
+
encodeURIComponent
(
location
.
href
);
// 登录失败留在当前登录页,保留原始回跳参数并显示接口错误。
if
(
stripAppBase
(
window
.
location
.
pathname
)
!==
'/login'
)
{
window
.
location
.
replace
(
getLoginUrl
(
window
.
location
.
href
));
}
}
else
if
(
response
?.
status
===
403
)
{
authService
.
clearUser
();
console
.
warn
(
'[request] forbidden (403)'
);
}
else
if
(
response
?.
status
>=
400
)
{
try
{
...
...
src/wrapper/Auth.jsx
View file @
2b4c88a1
import
authService
from
'@/services/authService'
;
import
{
getLoginRoute
}
from
'@/utils/authRedirect'
;
import
{
getUserInfo
,
userLogout
}
from
'@/services/DataService'
;
import
{
createContext
,
useEffect
,
useMemo
,
useState
}
from
'react'
;
import
{
history
,
Navigate
,
Outlet
,
useLocation
}
from
'umi'
;
...
...
@@ -93,9 +94,7 @@ export default function AuthWrapper() {
if
(
!
userInfo
||
!
authService
.
getToken
())
{
return
(
<
Navigate
to=
{
`/login?redirect=${encodeURIComponent(
location.pathname + location.search,
)}`
}
to=
{
getLoginRoute
(
location
.
pathname
+
location
.
search
+
location
.
hash
)
}
replace
/>
);
...
...
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