Update AI assistant
This commit is contained in:
@@ -69,7 +69,7 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 用户咨询项目功能 | 前端助手发送问题到 MCP 服务;MCP 服务检索知识库;必要时调用 DeepSeek 整理;返回 HTML 表格。 |
|
| 用户咨询项目功能 | 前端助手发送问题到 MCP 服务;MCP 服务检索知识库;必要时调用 DeepSeek 整理;返回 HTML 表格。 |
|
||||||
| 用户要求打开页面 | MCP 服务通过知识库匹配页面路径;前端助手收到 `navigate` 动作后调用 `$router.push`。 |
|
| 用户要求打开页面 | MCP 服务通过知识库匹配页面路径;前端助手收到 `navigate` 动作后调用 `$router.push`。 |
|
||||||
| 用户查询质量数据 | MCP 服务识别质量模块和查询条件;调用知识库中的只读接口或只读 SQL 白名单;返回数据表、字段说明和查询来源。 |
|
| 用户查询质量数据 | MCP 服务识别质量模块和查询条件;复用项目人机界面已有 `CreateData` 查询入口、`MESCommonBase.ashx` 或已有 `src/api` 函数;返回数据表、字段说明和查询来源。禁止直连数据库。 |
|
||||||
| 用户查看 SPC 图 | MCP 服务识别图表类型;调用对应 SPC 接口;标准化为 ECharts option、统计摘要、明细表;前端助手渲染图表和表格。 |
|
| 用户查看 SPC 图 | MCP 服务识别图表类型;调用对应 SPC 接口;标准化为 ECharts option、统计摘要、明细表;前端助手渲染图表和表格。 |
|
||||||
| DeepSeek 智能回答 | MCP 服务把知识库摘要、查询结果、图表摘要作为上下文发送给 DeepSeek;要求输出 HTML 表格化答案。 |
|
| DeepSeek 智能回答 | MCP 服务把知识库摘要、查询结果、图表摘要作为上下文发送给 DeepSeek;要求输出 HTML 表格化答案。 |
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
| 新增 | `mcp-server/*` | 新增本地 AI MCP 服务,独立运行。 |
|
| 新增 | `mcp-server/*` | 新增本地 AI MCP 服务,独立运行。 |
|
||||||
| 新增 | `docs/*` | 生成静态分析文档和知识库文件。 |
|
| 新增 | `docs/*` | 生成静态分析文档和知识库文件。 |
|
||||||
| 新增 | `scripts/*` | 新增启动、停止、状态检查脚本。 |
|
| 新增 | `scripts/*` | 新增启动、停止、状态检查脚本。 |
|
||||||
| 小范围调整 | `package.json` | 增加 AI MCP 启动、停止、知识库构建脚本;必要时增加 `mssql` 依赖。 |
|
| 小范围调整 | `package.json` | 增加 AI MCP 启动、停止、知识库构建脚本;不增加数据库直连依赖。 |
|
||||||
|
|
||||||
### 4.2 明确不修改的内容
|
### 4.2 明确不修改的内容
|
||||||
|
|
||||||
@@ -427,8 +427,7 @@ MCP 服务是一个运行在本机或内网服务器上的 Node.js 服务,作
|
|||||||
| `MES_BACKEND_URL` | 通用 MES 后端接口地址。 |
|
| `MES_BACKEND_URL` | 通用 MES 后端接口地址。 |
|
||||||
| `SPC_BACKEND_URL` | SPC 专用 `.ashx` 后端地址。 |
|
| `SPC_BACKEND_URL` | SPC 专用 `.ashx` 后端地址。 |
|
||||||
| `AI_MCP_ALLOW_BACKEND_CALLS` | 是否允许调用 MES 后端,默认仅只读查询。 |
|
| `AI_MCP_ALLOW_BACKEND_CALLS` | 是否允许调用 MES 后端,默认仅只读查询。 |
|
||||||
| `AI_MCP_ALLOW_SQL_QUERY` | 是否允许直接查询 SQL Server,默认可关闭。 |
|
| 数据查询约束 | 后续查询不直连 SQL Server,不配置数据库账号;只通过项目已有 HMI 函数、`CreateData`、`MESCommonBase.ashx`、已有 `src/api` 和 SPC 接口实现。 |
|
||||||
| `SQL_SERVER`、`SQL_DATABASE`、`SQL_USER`、`SQL_PASSWORD` | 只读数据库连接配置。 |
|
|
||||||
|
|
||||||
## 13. 任务节点与实施计划
|
## 13. 任务节点与实施计划
|
||||||
|
|
||||||
@@ -535,4 +534,3 @@ MCP 服务是一个运行在本机或内网服务器上的 Node.js 服务,作
|
|||||||
| 表格优先 | 回答、错误、来源、摘要都尽量表格化。 |
|
| 表格优先 | 回答、错误、来源、摘要都尽量表格化。 |
|
||||||
| 图表结构化 | SPC 图表用标准结构渲染,不执行模型生成脚本。 |
|
| 图表结构化 | SPC 图表用标准结构渲染,不执行模型生成脚本。 |
|
||||||
| 可追溯 | 每条数据回答都展示来源页面、接口或表对象。 |
|
| 可追溯 | 每条数据回答都展示来源页面、接口或表对象。 |
|
||||||
|
|
||||||
|
|||||||
146
MES-Manager_View/docs/module-feature-profiles.csv
Normal file
146
MES-Manager_View/docs/module-feature-profiles.csv
Normal file
File diff suppressed because one or more lines are too long
2
MES-Manager_View/docs/quality-data-query-profiles.csv
Normal file
2
MES-Manager_View/docs/quality-data-query-profiles.csv
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"Key","Title","RoutePath","View","Type","NameOrSql","ExportType","ExportNameOrSql","Description","Parameters","TableFields"
|
||||||
|
"assembly-quality-data","质量数据查询","/QualityAssurance/AssemblyQualitydataQuery","src\views\QualityAssurance\AssemblyQualitydataQuery\index.vue","11","质量数据_发动机质量数据_各个工位_视图_发动机型号_综合查询","2001","质量数据查询_综合查询新","按时间、工位、工件编号、订货号、订货号ID、机型号、工单ID、工单号、是否合格、测量位置、测量项目查询发动机各工位质量数据;无筛选条件时按全时间范围返回最新 200 条。","开始时间:startTime:1900-01-01 00:00:00|结束时间:endTime:2099-12-31 23:59:59|工位号_ischeck:stationNumber|工位号:stationNumber|PageCurrent:pageCurrent:1|PageSize:pageSize:200|PageCount:1111:1|ItemCount:1111:1|工件编号_ischeck:engineId|工件编号:engineId|订货号_ischeck:orderCode|订货号:orderCode|订货号ID_ischeck:orderCodeId|订货号ID:orderCodeId|机型号_ischeck:engineType|机型号:engineType|工单ID_ischeck:workOrderId|工单ID:workOrderId|工单号_ischeck:workOrderCode|工单号:workOrderCode|是否合格:isOk:0|测量位置_ischeck:testPosition|测量位置:testPosition|测量项目_ischeck:testItem|测量项目:testItem","序号:指南工位号|工位名称:工序名称|工件编号:发动机号|机型号:机型号|订货号:订货号|订货号ID:订货号ID|工单ID:工单ID|工单号:工单号|测量位置:测量位置|测量项目:测量项目|测量值:测量值|理论值:理论值|上限值:上限值|下限值:下限值|测量单位:测量单位|生产日期:生产日期|合格标志:合格标志"
|
||||||
|
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
# WC-SPC 项目静态分析
|
# WC-SPC 项目静态分析
|
||||||
|
|
||||||
生成时间:2026/5/27 14:23:16
|
生成时间:2026/5/27 16:14:27
|
||||||
|
|
||||||
## 结论
|
## 结论
|
||||||
|
|
||||||
@@ -18,4 +18,6 @@
|
|||||||
| API 映射 | 2585 |
|
| API 映射 | 2585 |
|
||||||
| Raw SQL | 24 |
|
| Raw SQL | 24 |
|
||||||
| 质量相关模块 | 37 |
|
| 质量相关模块 | 37 |
|
||||||
|
| 质量数据查询画像 | 1 |
|
||||||
|
| 页面功能画像 | 145 |
|
||||||
| SPC 图表类型 | 8 |
|
| SPC 图表类型 | 8 |
|
||||||
@@ -31,6 +31,8 @@ AI_MCP_ALLOW_BACKEND_CALLS=false
|
|||||||
|
|
||||||
未配置 `DEEPSEEK_API_KEY` 时,助手仍可基于本地知识库回答项目功能、质量模块和 SPC 页面映射问题。
|
未配置 `DEEPSEEK_API_KEY` 时,助手仍可基于本地知识库回答项目功能、质量模块和 SPC 页面映射问题。
|
||||||
|
|
||||||
|
数据查询约束:AI 助手不直连 SQL Server,不依赖数据库账号。所有数据查询必须复用项目人机界面已有能力,例如 Vue 页面中的 `CreateData` 参数、`MESCommonBase.ashx` 查询入口、已有 `src/api` 函数和 SPC 专用接口。
|
||||||
|
|
||||||
## 端点
|
## 端点
|
||||||
|
|
||||||
- `GET /health`
|
- `GET /health`
|
||||||
@@ -44,9 +46,11 @@ AI_MCP_ALLOW_BACKEND_CALLS=false
|
|||||||
- `search_project_functions`
|
- `search_project_functions`
|
||||||
- `list_quality_modules`
|
- `list_quality_modules`
|
||||||
- `search_quality_functions`
|
- `search_quality_functions`
|
||||||
|
- `describe_quality_data_query`
|
||||||
|
- `query_quality_data`:复用 `/QualityAssurance/AssemblyQualitydataQuery` 页面 `selectDate()` 的 `CreateData('11', '质量数据_发动机质量数据_各个工位_视图_发动机型号_综合查询', param)` 参数结构;有条件按条件查,无条件按全时间范围、无筛选条件返回最新 200 条。
|
||||||
- `list_spc_charts`
|
- `list_spc_charts`
|
||||||
- `spc_query_chart`
|
- `spc_query_chart`
|
||||||
- `reload_project_index`
|
- `reload_project_index`
|
||||||
- `call_mes_backend`
|
- `call_mes_backend`
|
||||||
|
|
||||||
`call_mes_backend` 默认禁用,避免 AI 直接执行会修改业务数据的后端操作。
|
`call_mes_backend` 默认禁用,避免 AI 直接执行会修改业务数据的后端操作。查询类能力应优先使用已解析出的页面查询画像和项目已有查询入口。
|
||||||
|
|||||||
@@ -149,6 +149,286 @@ function inferObjectFromName(name) {
|
|||||||
.replace(/_删除.*$/, '')
|
.replace(/_删除.*$/, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAttr(attrs, name) {
|
||||||
|
const re = new RegExp('(?:^|\\s)(?::?' + name + '|v-bind:' + name + ')\\s*=\\s*([\'"])([\\s\\S]*?)\\1', 'i')
|
||||||
|
const match = String(attrs || '').match(re)
|
||||||
|
return match ? match[2].replace(/\s+/g, ' ').trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function textFromTemplate(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.replace(/<!--[\s\S]*?-->/g, '')
|
||||||
|
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||||
|
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
|
.replace(/\{\{|\}\}/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractClickMethod(value) {
|
||||||
|
const methods = []
|
||||||
|
const re = /([A-Za-z_$\u4e00-\u9fa5][\w$\u4e00-\u9fa5]*)\s*\(/g
|
||||||
|
let match
|
||||||
|
while ((match = re.exec(String(value || '')))) methods.push(match[1])
|
||||||
|
return methods.filter(item => !/^(if|for|while|switch|confirm|parseInt|Number|String)$/.test(item)).pop() || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function findBalancedBlock(text, openBraceIndex) {
|
||||||
|
if (openBraceIndex < 0) return ''
|
||||||
|
let depth = 0
|
||||||
|
let quote = ''
|
||||||
|
let escaped = false
|
||||||
|
for (let i = openBraceIndex; i < text.length; i++) {
|
||||||
|
const ch = text[i]
|
||||||
|
if (quote) {
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false
|
||||||
|
} else if (ch === '\\') {
|
||||||
|
escaped = true
|
||||||
|
} else if (ch === quote) {
|
||||||
|
quote = ''
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (ch === '"' || ch === "'" || ch === '`') {
|
||||||
|
quote = ch
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (ch === '{') depth++
|
||||||
|
if (ch === '}') {
|
||||||
|
depth--
|
||||||
|
if (depth === 0) return text.slice(openBraceIndex + 1, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractOptionsBlock(text, name) {
|
||||||
|
const re = new RegExp('\\b' + name + '\\s*:\\s*{', 'g')
|
||||||
|
const match = re.exec(text)
|
||||||
|
if (!match) return ''
|
||||||
|
return findBalancedBlock(text, match.index + match[0].lastIndexOf('{'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractLifecycleBlock(text, name) {
|
||||||
|
const patterns = [
|
||||||
|
new RegExp('\\b' + name + '\\s*\\([^)]*\\)\\s*{', 'g'),
|
||||||
|
new RegExp('\\b' + name + '\\s*:\\s*function\\s*\\([^)]*\\)\\s*{', 'g')
|
||||||
|
]
|
||||||
|
for (let i = 0; i < patterns.length; i++) {
|
||||||
|
const match = patterns[i].exec(text)
|
||||||
|
if (match) return findBalancedBlock(text, match.index + match[0].lastIndexOf('{'))
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractButtons(text) {
|
||||||
|
const rows = []
|
||||||
|
const re = /<el-button\b([^>]*)>([\s\S]*?)<\/el-button>/gi
|
||||||
|
let match
|
||||||
|
while ((match = re.exec(text))) {
|
||||||
|
const attrs = match[1]
|
||||||
|
const label = textFromTemplate(match[2]) || getAttr(attrs, 'title') || getAttr(attrs, 'icon') || '按钮'
|
||||||
|
const click = getAttr(attrs, '@click') || getAttr(attrs, 'click') || getAttr(attrs, '@click.native.prevent') || getAttr(attrs, '@click.native')
|
||||||
|
rows.push({
|
||||||
|
label,
|
||||||
|
action: inferAction(label + ' ' + click) || '操作',
|
||||||
|
click,
|
||||||
|
method: extractClickMethod(click),
|
||||||
|
icon: getAttr(attrs, 'icon'),
|
||||||
|
type: getAttr(attrs, 'type')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return unique(rows.map(row => JSON.stringify(row))).map(row => JSON.parse(row)).slice(0, 40)
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFilterControls(text) {
|
||||||
|
const rows = []
|
||||||
|
const re = /<(el-date-picker|el-select|el-input|el-cascader|el-checkbox|el-radio-group|el-switch|el-input-number)\b([^>]*)/gi
|
||||||
|
let match
|
||||||
|
while ((match = re.exec(text))) {
|
||||||
|
const attrs = match[2]
|
||||||
|
const model = getAttr(attrs, 'v-model') || getAttr(attrs, 'model')
|
||||||
|
const label = getAttr(attrs, 'placeholder') || getAttr(attrs, 'start-placeholder') || getAttr(attrs, 'end-placeholder') || model || match[1]
|
||||||
|
rows.push({
|
||||||
|
control: match[1],
|
||||||
|
label,
|
||||||
|
model,
|
||||||
|
type: getAttr(attrs, 'type'),
|
||||||
|
clearable: /\sclearable(\s|>|$)/i.test(attrs),
|
||||||
|
filterable: /\sfilterable(\s|>|$)/i.test(attrs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return unique(rows.map(row => JSON.stringify(row))).map(row => JSON.parse(row)).slice(0, 40)
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTableFields(text) {
|
||||||
|
const rows = []
|
||||||
|
const re = /<el-table-column\b([^>]*)(?:\/>|>([\s\S]*?)<\/el-table-column>)/gi
|
||||||
|
let match
|
||||||
|
while ((match = re.exec(text))) {
|
||||||
|
const attrs = match[1]
|
||||||
|
const body = match[2] || ''
|
||||||
|
const rowRefs = []
|
||||||
|
const refRe = /scope\.row\.([A-Za-z0-9_$\u4e00-\u9fa5]+)/g
|
||||||
|
let refMatch
|
||||||
|
while ((refMatch = refRe.exec(body))) rowRefs.push(refMatch[1])
|
||||||
|
const prop = getAttr(attrs, 'prop') || unique(rowRefs).join('|')
|
||||||
|
const label = getAttr(attrs, 'label') || prop || textFromTemplate(body).slice(0, 30)
|
||||||
|
if (!label && !prop) continue
|
||||||
|
rows.push({
|
||||||
|
label,
|
||||||
|
prop,
|
||||||
|
width: getAttr(attrs, 'width') || getAttr(attrs, 'min-width'),
|
||||||
|
fixed: getAttr(attrs, 'fixed')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return unique(rows.map(row => JSON.stringify(row))).map(row => JSON.parse(row)).slice(0, 80)
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMethodProfiles(text) {
|
||||||
|
const block = extractOptionsBlock(text, 'methods')
|
||||||
|
if (!block) return []
|
||||||
|
const rows = []
|
||||||
|
const skip = /^(if|for|while|switch|catch|then|map|filter|forEach|setTimeout|setInterval)$/
|
||||||
|
const re = /(?:^|\n)\s*([A-Za-z_$\u4e00-\u9fa5][\w$\u4e00-\u9fa5]*)\s*(?:\([^)]*\)\s*{|:\s*function\s*\([^)]*\)\s*{)/g
|
||||||
|
let match
|
||||||
|
while ((match = re.exec(block))) {
|
||||||
|
const name = match[1]
|
||||||
|
if (skip.test(name)) continue
|
||||||
|
rows.push({
|
||||||
|
name,
|
||||||
|
action: inferAction(name),
|
||||||
|
line: lineNumber(text, text.indexOf(match[0].trim()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return unique(rows.map(row => JSON.stringify(row))).map(row => JSON.parse(row)).slice(0, 80)
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractLifecycleProfiles(text) {
|
||||||
|
return ['created', 'mounted'].map(name => {
|
||||||
|
const block = extractLifecycleBlock(text, name)
|
||||||
|
const calls = []
|
||||||
|
const re = /this\.([A-Za-z_$\u4e00-\u9fa5][\w$\u4e00-\u9fa5]*)\s*\(/g
|
||||||
|
let match
|
||||||
|
while ((match = re.exec(block))) calls.push(match[1])
|
||||||
|
return { hook: name, calls: unique(calls) }
|
||||||
|
}).filter(item => item.calls.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
function riskLevelFromCalls(calls, buttons) {
|
||||||
|
const text = calls.concat(buttons || []).map(item => [item.Action, item.Type, item.NameOrSql, item.action, item.label].join(' ')).join(' ')
|
||||||
|
if (/删除|delete/i.test(text)) return '包含删除操作'
|
||||||
|
if (/修改|编辑|更新|增加|新增|导入|上传|保存|type.?12/i.test(text)) return '包含写入操作'
|
||||||
|
if (/导出|Excel|下载/i.test(text)) return '包含导出操作'
|
||||||
|
return '只读查询为主'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildModuleFeatureProfiles(viewCalls, viewImports, apiMappings) {
|
||||||
|
const views = walk(path.join(ROOT, 'src', 'views'), file => /\.vue$/i.test(file)).map(rel).sort()
|
||||||
|
return views.map(view => {
|
||||||
|
const fullPath = path.join(ROOT, view)
|
||||||
|
const text = readText(fullPath)
|
||||||
|
const calls = viewCalls.filter(row => row.View === view)
|
||||||
|
const imports = viewImports.filter(row => row.View === view)
|
||||||
|
const apiFiles = unique(imports.map(row => row.ApiFile))
|
||||||
|
const apiFunctions = unique(apiFiles.flatMap(file => apiMappings
|
||||||
|
.filter(row => normalizeSlash(row.ApiFile) === normalizeSlash(file))
|
||||||
|
.map(row => row.FunctionName)))
|
||||||
|
.filter(name => new RegExp('\\b' + name + '\\s*\\(').test(text))
|
||||||
|
const buttons = extractButtons(text)
|
||||||
|
const filters = extractFilterControls(text)
|
||||||
|
const tableFields = extractTableFields(text)
|
||||||
|
const methods = extractMethodProfiles(text)
|
||||||
|
const lifecycle = extractLifecycleProfiles(text)
|
||||||
|
const actions = unique(buttons.map(row => row.action).concat(calls.map(row => row.Action))).filter(Boolean)
|
||||||
|
const backendCalls = calls.map(row => ({
|
||||||
|
source: row.Source,
|
||||||
|
type: row.Type,
|
||||||
|
action: row.Action,
|
||||||
|
domain: row.Domain,
|
||||||
|
nameOrSql: row.NameOrSql,
|
||||||
|
line: Number(row.Line || 0),
|
||||||
|
role: row.Source === 'RawSQL' ? 'Raw SQL 查询' : 'MESCommonBase 查询入口'
|
||||||
|
}))
|
||||||
|
const title = displayTitleFromView(view)
|
||||||
|
const description = title + ' 页面支持 ' + (actions.length ? actions.join('、') : '页面查看') +
|
||||||
|
';主要控件 ' + (filters.length ? filters.map(row => row.label).slice(0, 8).join('、') : '无显式筛选控件') +
|
||||||
|
';表格字段 ' + (tableFields.length ? tableFields.map(row => row.label).slice(0, 10).join('、') : '未识别到表格字段') + '。'
|
||||||
|
return {
|
||||||
|
view,
|
||||||
|
module: moduleFromView(view),
|
||||||
|
title,
|
||||||
|
routePath: viewToRoutePath(view),
|
||||||
|
filters,
|
||||||
|
buttons,
|
||||||
|
tableFields,
|
||||||
|
lifecycle,
|
||||||
|
methods,
|
||||||
|
backendCalls,
|
||||||
|
apiFiles,
|
||||||
|
apiFunctions,
|
||||||
|
actions,
|
||||||
|
riskLevel: riskLevelFromCalls(calls, buttons),
|
||||||
|
description,
|
||||||
|
keywords: keywordsFromText([view, title, actions.join(' '), filters.map(row => row.label).join(' '), buttons.map(row => row.label).join(' '), tableFields.map(row => row.label).join(' '), backendCalls.map(row => row.nameOrSql).join(' ')].join(' '))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQualityDataQueryProfiles(moduleFeatureProfiles) {
|
||||||
|
const assembly = moduleFeatureProfiles.find(profile => normalizeSlash(profile.view) === 'src\\views\\QualityAssurance\\AssemblyQualitydataQuery\\index.vue')
|
||||||
|
if (!assembly) return []
|
||||||
|
return [{
|
||||||
|
key: 'assembly-quality-data',
|
||||||
|
title: '质量数据查询',
|
||||||
|
routePath: assembly.routePath,
|
||||||
|
view: assembly.view,
|
||||||
|
module: assembly.module,
|
||||||
|
method: 'selectDate',
|
||||||
|
type: '11',
|
||||||
|
nameOrSql: '质量数据_发动机质量数据_各个工位_视图_发动机型号_综合查询',
|
||||||
|
exportType: '2001',
|
||||||
|
exportNameOrSql: '质量数据查询_综合查询新',
|
||||||
|
description: '按时间、工位、工件编号、订货号、订货号ID、机型号、工单ID、工单号、是否合格、测量位置、测量项目查询发动机各工位质量数据;无筛选条件时按全时间范围返回最新 200 条。',
|
||||||
|
defaultStrategy: '无查询条件时使用 1900-01-01 00:00:00 至 2099-12-31 23:59:59、全部筛选_ischeck=0、PageCurrent=1、PageSize=200,并按生产日期等时间字段倒序展示最新 200 条。',
|
||||||
|
latestSortFields: ['生产日期', '操作时间', '完成时间', '到达时间', '离开时间', '时间'],
|
||||||
|
defaultUseWhen: ['查询质量数据', '质量数据查询', '发动机质量数据', '工件质量数据', '测量值查询'],
|
||||||
|
parameters: [
|
||||||
|
{ name: '开始时间', source: 'dateTime[0]', arg: 'startTime', defaultValue: '1900-01-01 00:00:00', required: true },
|
||||||
|
{ name: '结束时间', source: 'dateTime[1]', arg: 'endTime', defaultValue: '2099-12-31 23:59:59', required: true },
|
||||||
|
{ name: '工位号_ischeck', source: 'stationNumberValue', arg: 'stationNumber', checkFor: '工位号' },
|
||||||
|
{ name: '工位号', source: 'stationNumberValue', arg: 'stationNumber' },
|
||||||
|
{ name: 'PageCurrent', source: 'pageCurrent', arg: 'pageCurrent', defaultValue: 1 },
|
||||||
|
{ name: 'PageSize', source: 'pageSize', arg: 'pageSize', defaultValue: 200 },
|
||||||
|
{ name: 'PageCount', source: 'output', defaultValue: '1111', type: 'int', output: '1' },
|
||||||
|
{ name: 'ItemCount', source: 'output', defaultValue: '1111', type: 'int', output: '1' },
|
||||||
|
{ name: '工件编号_ischeck', source: 'engineId', arg: 'engineId', checkFor: '工件编号' },
|
||||||
|
{ name: '工件编号', source: 'engineId', arg: 'engineId' },
|
||||||
|
{ name: '订货号_ischeck', source: 'orderCode', arg: 'orderCode', checkFor: '订货号' },
|
||||||
|
{ name: '订货号', source: 'orderCode', arg: 'orderCode' },
|
||||||
|
{ name: '订货号ID_ischeck', source: 'orderCodeId', arg: 'orderCodeId', checkFor: '订货号ID' },
|
||||||
|
{ name: '订货号ID', source: 'orderCodeId', arg: 'orderCodeId' },
|
||||||
|
{ name: '机型号_ischeck', source: 'engineType', arg: 'engineType', checkFor: '机型号' },
|
||||||
|
{ name: '机型号', source: 'engineType', arg: 'engineType' },
|
||||||
|
{ name: '工单ID_ischeck', source: 'workOrderId', arg: 'workOrderId', checkFor: '工单ID' },
|
||||||
|
{ name: '工单ID', source: 'workOrderId', arg: 'workOrderId' },
|
||||||
|
{ name: '工单号_ischeck', source: 'workOrderCode', arg: 'workOrderCode', checkFor: '工单号' },
|
||||||
|
{ name: '工单号', source: 'workOrderCode', arg: 'workOrderCode' },
|
||||||
|
{ name: '是否合格', source: 'isOk', arg: 'isOk', defaultValue: '0' },
|
||||||
|
{ name: '测量位置_ischeck', source: 'testPosition', arg: 'testPosition', checkFor: '测量位置' },
|
||||||
|
{ name: '测量位置', source: 'testPosition', arg: 'testPosition' },
|
||||||
|
{ name: '测量项目_ischeck', source: 'testItem', arg: 'testItem', checkFor: '测量项目' },
|
||||||
|
{ name: '测量项目', source: 'testItem', arg: 'testItem' }
|
||||||
|
],
|
||||||
|
tableFields: assembly.tableFields,
|
||||||
|
filters: assembly.filters,
|
||||||
|
backendCalls: assembly.backendCalls,
|
||||||
|
keywords: keywordsFromText([assembly.title, assembly.routePath, assembly.description, '查询质量数据 质量数据查询 发动机质量数据 工件质量数据 测量值查询'].join(' '))
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
function extractApiMappings() {
|
function extractApiMappings() {
|
||||||
const apiRoots = [
|
const apiRoots = [
|
||||||
path.join(ROOT, 'src', 'api'),
|
path.join(ROOT, 'src', 'api'),
|
||||||
@@ -344,8 +624,11 @@ function main() {
|
|||||||
})
|
})
|
||||||
const spcMappings = buildSpcMappings(apiMappings, viewCalls)
|
const spcMappings = buildSpcMappings(apiMappings, viewCalls)
|
||||||
const qualityModules = buildQualityModules(viewCalls)
|
const qualityModules = buildQualityModules(viewCalls)
|
||||||
|
const moduleFeatureProfiles = buildModuleFeatureProfiles(viewCalls, viewImports, apiMappings)
|
||||||
|
const qualityDataQueries = buildQualityDataQueryProfiles(moduleFeatureProfiles)
|
||||||
const modules = viewSummary.map(row => {
|
const modules = viewSummary.map(row => {
|
||||||
const calls = viewCalls.filter(call => call.View === row.View)
|
const calls = viewCalls.filter(call => call.View === row.View)
|
||||||
|
const featureProfile = moduleFeatureProfiles.find(profile => profile.view === row.View)
|
||||||
const primaryQueries = calls.filter(call => call.Queryable).map(call => ({
|
const primaryQueries = calls.filter(call => call.Queryable).map(call => ({
|
||||||
kind: call.Source === 'RawSQL' ? 'raw-sql' : 'create-data',
|
kind: call.Source === 'RawSQL' ? 'raw-sql' : 'create-data',
|
||||||
view: call.View,
|
view: call.View,
|
||||||
@@ -355,7 +638,9 @@ function main() {
|
|||||||
action: call.Action,
|
action: call.Action,
|
||||||
domain: call.Domain,
|
domain: call.Domain,
|
||||||
nameOrSql: call.NameOrSql,
|
nameOrSql: call.NameOrSql,
|
||||||
queryTables: call.Source === 'RawSQL' ? extractTables(call.NameOrSql) : [inferObjectFromName(call.NameOrSql)].filter(Boolean),
|
queryTables: call.Source === 'RawSQL' ? extractTables(call.NameOrSql) : [],
|
||||||
|
backendQueryName: call.Source === 'RawSQL' ? '' : call.NameOrSql,
|
||||||
|
inferredObject: call.Source === 'RawSQL' ? '' : inferObjectFromName(call.NameOrSql),
|
||||||
keywords: keywordsFromText([call.View, call.Domain, call.NameOrSql].join(' '))
|
keywords: keywordsFromText([call.View, call.Domain, call.NameOrSql].join(' '))
|
||||||
}))
|
}))
|
||||||
return {
|
return {
|
||||||
@@ -366,6 +651,7 @@ function main() {
|
|||||||
callCount: Number(row.Count || calls.length || 0),
|
callCount: Number(row.Count || calls.length || 0),
|
||||||
domains: row.Domains ? row.Domains.split('|') : [],
|
domains: row.Domains ? row.Domains.split('|') : [],
|
||||||
functionNames: row.Names ? row.Names.split('|') : [],
|
functionNames: row.Names ? row.Names.split('|') : [],
|
||||||
|
featureProfile,
|
||||||
calls,
|
calls,
|
||||||
primaryQueries,
|
primaryQueries,
|
||||||
queryable: primaryQueries.length > 0,
|
queryable: primaryQueries.length > 0,
|
||||||
@@ -390,14 +676,15 @@ function main() {
|
|||||||
})
|
})
|
||||||
modules.forEach(module => {
|
modules.forEach(module => {
|
||||||
module.primaryQueries.forEach(query => {
|
module.primaryQueries.forEach(query => {
|
||||||
|
if (query.kind !== 'raw-sql') return
|
||||||
;(query.queryTables || []).forEach(table => {
|
;(query.queryTables || []).forEach(table => {
|
||||||
if (!table) return
|
if (!table) return
|
||||||
sqlTables.push({
|
sqlTables.push({
|
||||||
table,
|
table,
|
||||||
sourceFile: module.view,
|
sourceFile: module.view,
|
||||||
line: query.line,
|
line: query.line,
|
||||||
sql: query.kind === 'raw-sql' ? query.nameOrSql : '',
|
sql: query.nameOrSql,
|
||||||
source: query.kind === 'raw-sql' ? 'raw-sql' : 'inferred-object',
|
source: 'raw-sql',
|
||||||
queryable: true,
|
queryable: true,
|
||||||
keywords: keywordsFromText([table, module.view, query.nameOrSql].join(' '))
|
keywords: keywordsFromText([table, module.view, query.nameOrSql].join(' '))
|
||||||
})
|
})
|
||||||
@@ -435,6 +722,8 @@ function main() {
|
|||||||
})),
|
})),
|
||||||
sqlTables: dedupedTables,
|
sqlTables: dedupedTables,
|
||||||
qualityModules,
|
qualityModules,
|
||||||
|
moduleFeatureProfiles,
|
||||||
|
qualityDataQueries,
|
||||||
spcCharts: spcMappings,
|
spcCharts: spcMappings,
|
||||||
queryHints: modules.map(item => ({
|
queryHints: modules.map(item => ({
|
||||||
kind: 'module',
|
kind: 'module',
|
||||||
@@ -451,6 +740,64 @@ function main() {
|
|||||||
writeCsv('raw-sql-table-mapping.csv', ['ApiFile', 'Line', 'Tables', 'Sql'], rawSqlRows)
|
writeCsv('raw-sql-table-mapping.csv', ['ApiFile', 'Line', 'Tables', 'Sql'], rawSqlRows)
|
||||||
writeCsv('spc-chart-mapping.csv', ['chartType', 'title', 'apiFile', 'endpoint', 'functions', 'types', 'views'], spcMappings)
|
writeCsv('spc-chart-mapping.csv', ['chartType', 'title', 'apiFile', 'endpoint', 'functions', 'types', 'views'], spcMappings)
|
||||||
writeCsv('quality-module-mapping.csv', ['View', 'Module', 'RoutePath', 'Title', 'Domains', 'QueryNames', 'Queryable'], qualityModules)
|
writeCsv('quality-module-mapping.csv', ['View', 'Module', 'RoutePath', 'Title', 'Domains', 'QueryNames', 'Queryable'], qualityModules)
|
||||||
|
writeCsv('quality-data-query-profiles.csv', [
|
||||||
|
'Key',
|
||||||
|
'Title',
|
||||||
|
'RoutePath',
|
||||||
|
'View',
|
||||||
|
'Type',
|
||||||
|
'NameOrSql',
|
||||||
|
'ExportType',
|
||||||
|
'ExportNameOrSql',
|
||||||
|
'Description',
|
||||||
|
'Parameters',
|
||||||
|
'TableFields'
|
||||||
|
], qualityDataQueries.map(profile => ({
|
||||||
|
Key: profile.key,
|
||||||
|
Title: profile.title,
|
||||||
|
RoutePath: profile.routePath,
|
||||||
|
View: profile.view,
|
||||||
|
Type: profile.type,
|
||||||
|
NameOrSql: profile.nameOrSql,
|
||||||
|
ExportType: profile.exportType,
|
||||||
|
ExportNameOrSql: profile.exportNameOrSql,
|
||||||
|
Description: profile.description,
|
||||||
|
Parameters: profile.parameters.map(row => [row.name, row.arg, row.defaultValue, row.output].filter(value => value != null && value !== '').join(':')).join('|'),
|
||||||
|
TableFields: profile.tableFields.map(row => [row.label, row.prop].filter(Boolean).join(':')).join('|')
|
||||||
|
})))
|
||||||
|
writeCsv('module-feature-profiles.csv', [
|
||||||
|
'View',
|
||||||
|
'Module',
|
||||||
|
'RoutePath',
|
||||||
|
'Title',
|
||||||
|
'Description',
|
||||||
|
'Actions',
|
||||||
|
'Filters',
|
||||||
|
'Buttons',
|
||||||
|
'TableFields',
|
||||||
|
'Lifecycle',
|
||||||
|
'Methods',
|
||||||
|
'BackendCalls',
|
||||||
|
'ApiFiles',
|
||||||
|
'ApiFunctions',
|
||||||
|
'RiskLevel'
|
||||||
|
], moduleFeatureProfiles.map(profile => ({
|
||||||
|
View: profile.view,
|
||||||
|
Module: profile.module,
|
||||||
|
RoutePath: profile.routePath,
|
||||||
|
Title: profile.title,
|
||||||
|
Description: profile.description,
|
||||||
|
Actions: profile.actions.join('|'),
|
||||||
|
Filters: profile.filters.map(row => [row.label, row.model, row.control].filter(Boolean).join(':')).join('|'),
|
||||||
|
Buttons: profile.buttons.map(row => [row.label, row.method, row.action].filter(Boolean).join(':')).join('|'),
|
||||||
|
TableFields: profile.tableFields.map(row => [row.label, row.prop].filter(Boolean).join(':')).join('|'),
|
||||||
|
Lifecycle: profile.lifecycle.map(row => row.hook + ':' + row.calls.join('/')).join('|'),
|
||||||
|
Methods: profile.methods.map(row => [row.name, row.action].filter(Boolean).join(':')).join('|'),
|
||||||
|
BackendCalls: profile.backendCalls.map(row => [row.type, row.action, row.nameOrSql].filter(Boolean).join(':')).join('|'),
|
||||||
|
ApiFiles: profile.apiFiles.join('|'),
|
||||||
|
ApiFunctions: profile.apiFunctions.join('|'),
|
||||||
|
RiskLevel: profile.riskLevel
|
||||||
|
})))
|
||||||
fs.writeFileSync(OUT_FILE, JSON.stringify(knowledgeBase, null, 2), 'utf8')
|
fs.writeFileSync(OUT_FILE, JSON.stringify(knowledgeBase, null, 2), 'utf8')
|
||||||
const report = [
|
const report = [
|
||||||
'# WC-SPC 项目静态分析',
|
'# WC-SPC 项目静态分析',
|
||||||
@@ -473,6 +820,8 @@ function main() {
|
|||||||
'| API 映射 | ' + apiMappings.length + ' |',
|
'| API 映射 | ' + apiMappings.length + ' |',
|
||||||
'| Raw SQL | ' + rawSqlRows.length + ' |',
|
'| Raw SQL | ' + rawSqlRows.length + ' |',
|
||||||
'| 质量相关模块 | ' + qualityModules.length + ' |',
|
'| 质量相关模块 | ' + qualityModules.length + ' |',
|
||||||
|
'| 质量数据查询画像 | ' + qualityDataQueries.length + ' |',
|
||||||
|
'| 页面功能画像 | ' + moduleFeatureProfiles.length + ' |',
|
||||||
'| SPC 图表类型 | ' + spcMappings.length + ' |'
|
'| SPC 图表类型 | ' + spcMappings.length + ' |'
|
||||||
].join('\n')
|
].join('\n')
|
||||||
fs.writeFileSync(path.join(DOCS_DIR, 'wc-spc-static-analysis.md'), report, 'utf8')
|
fs.writeFileSync(path.join(DOCS_DIR, 'wc-spc-static-analysis.md'), report, 'utf8')
|
||||||
@@ -480,6 +829,8 @@ function main() {
|
|||||||
console.log('modules=' + modules.length)
|
console.log('modules=' + modules.length)
|
||||||
console.log('apiMappings=' + apiMappings.length)
|
console.log('apiMappings=' + apiMappings.length)
|
||||||
console.log('qualityModules=' + qualityModules.length)
|
console.log('qualityModules=' + qualityModules.length)
|
||||||
|
console.log('qualityDataQueries=' + qualityDataQueries.length)
|
||||||
|
console.log('moduleFeatureProfiles=' + moduleFeatureProfiles.length)
|
||||||
console.log('spcCharts=' + spcMappings.length)
|
console.log('spcCharts=' + spcMappings.length)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
// export const ip = 'http://localhost:57966'
|
// export const ip = 'http://localhost:57966'
|
||||||
export const ip = 'http://localhost:57966'
|
export const ip = 'http://localhost:10050'
|
||||||
// export const ip = 'http://10.81.128.42:8011'
|
// export const ip = 'http://10.81.128.42:8011'
|
||||||
// export const ip = 'http://10.81.128.42:8011'
|
// export const ip = 'http://10.81.128.42:8011'
|
||||||
// export const ip = 'http://124.220.15.242:8154' // AP
|
// export const ip = 'http://124.220.15.242:8154' // AP
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export default {
|
|||||||
this.collapsed = !this.collapsed
|
this.collapsed = !this.collapsed
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.keepInViewport()
|
this.keepInViewport()
|
||||||
this.resizeCharts()
|
this.resizeChartsAfterLayout()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
toggleSize() {
|
toggleSize() {
|
||||||
@@ -125,7 +125,7 @@ export default {
|
|||||||
this.size = sizes[(index + 1) % sizes.length]
|
this.size = sizes[(index + 1) % sizes.length]
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.keepInViewport()
|
this.keepInViewport()
|
||||||
this.resizeCharts()
|
this.resizeChartsAfterLayout()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
clearMessages() {
|
clearMessages() {
|
||||||
@@ -156,9 +156,9 @@ export default {
|
|||||||
},
|
},
|
||||||
getPanelSize() {
|
getPanelSize() {
|
||||||
const sizes = {
|
const sizes = {
|
||||||
small: { width: 360, height: 460 },
|
small: { width: 520, height: 560 },
|
||||||
medium: { width: 440, height: 580 },
|
medium: { width: 720, height: 680 },
|
||||||
large: { width: 760, height: 700 }
|
large: { width: 960, height: 780 }
|
||||||
}
|
}
|
||||||
return this.collapsed ? { width: 56, height: 48 } : sizes[this.size] || sizes.medium
|
return this.collapsed ? { width: 56, height: 48 } : sizes[this.size] || sizes.medium
|
||||||
},
|
},
|
||||||
@@ -169,7 +169,7 @@ export default {
|
|||||||
},
|
},
|
||||||
handleResize() {
|
handleResize() {
|
||||||
this.keepInViewport()
|
this.keepInViewport()
|
||||||
this.resizeCharts()
|
this.resizeChartsAfterLayout()
|
||||||
},
|
},
|
||||||
scrollToBottom() {
|
scrollToBottom() {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
@@ -231,6 +231,10 @@ export default {
|
|||||||
if (!Array.isArray(actions)) return
|
if (!Array.isArray(actions)) return
|
||||||
actions.forEach(action => {
|
actions.forEach(action => {
|
||||||
if (!action || action.type !== 'navigate' || !action.path) return
|
if (!action || action.type !== 'navigate' || !action.path) return
|
||||||
|
if (action.url) {
|
||||||
|
window.location.href = action.url
|
||||||
|
return
|
||||||
|
}
|
||||||
this.$router.push({ path: action.path }).catch(() => {})
|
this.$router.push({ path: action.path }).catch(() => {})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -244,11 +248,19 @@ export default {
|
|||||||
instance.setOption(chart.option)
|
instance.setOption(chart.option)
|
||||||
this.chartInstances[chart.id] = instance
|
this.chartInstances[chart.id] = instance
|
||||||
})
|
})
|
||||||
|
this.resizeChartsAfterLayout()
|
||||||
},
|
},
|
||||||
resizeCharts() {
|
resizeCharts() {
|
||||||
Object.keys(this.chartInstances).forEach(key => {
|
Object.keys(this.chartInstances).forEach(key => {
|
||||||
this.chartInstances[key].resize()
|
this.chartInstances[key].resize()
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
resizeChartsAfterLayout() {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.resizeCharts()
|
||||||
|
window.setTimeout(this.resizeCharts, 80)
|
||||||
|
window.setTimeout(this.resizeCharts, 220)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,18 +278,18 @@ export default {
|
|||||||
transition: width .18s ease, height .18s ease, box-shadow .18s ease;
|
transition: width .18s ease, height .18s ease, box-shadow .18s ease;
|
||||||
|
|
||||||
&.is-small {
|
&.is-small {
|
||||||
width: 360px;
|
width: 520px;
|
||||||
height: 460px;
|
height: 560px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-medium {
|
&.is-medium {
|
||||||
width: 440px;
|
width: 720px;
|
||||||
height: 580px;
|
height: 680px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-large {
|
&.is-large {
|
||||||
width: 760px;
|
width: 960px;
|
||||||
height: 700px;
|
height: 780px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-collapsed {
|
&.is-collapsed {
|
||||||
@@ -402,14 +414,57 @@ export default {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-chart-wrap {
|
.ai-detail-table-wrap {
|
||||||
margin: 10px 0;
|
width: 100%;
|
||||||
border: 1px solid #ebeef5;
|
overflow-x: auto;
|
||||||
|
margin: 6px 0 12px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ai-detail-table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 704px;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
background: #fff;
|
||||||
|
color: #303133;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-detail-table th,
|
||||||
|
.ai-detail-table td {
|
||||||
|
border: 1px solid #d8dee9;
|
||||||
|
padding: 6px 8px;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
line-height: 18px;
|
||||||
|
word-break: break-word;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-detail-table th {
|
||||||
|
background: #eef3fb;
|
||||||
|
color: #4b5563;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-detail-table .ai-status-ng {
|
||||||
|
color: #ff0000;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-chart-wrap {
|
||||||
|
margin: 12px 0;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #d8dee9;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 2px 8px rgba(31, 41, 55, 0.06);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.ai-chart {
|
.ai-chart {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 300px;
|
height: 340px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user