feat: 參考backend

This commit is contained in:
skytek_xinliang
2026-05-12 11:03:46 +08:00
parent 10843227a8
commit 58a5a525d7
9 changed files with 1488 additions and 69 deletions
+183
View File
@@ -0,0 +1,183 @@
export function buildMaintenanceContract({ route, spec, apiMatches = [] }) {
const pageKind = inferPageKind(route, spec, apiMatches)
const capabilities = inferCapabilities(spec, apiMatches)
const dataModel = inferDataModel(route, spec, apiMatches)
const rowActions = inferRowActions(spec)
const template = recommendTemplate(pageKind, spec, capabilities, dataModel)
return {
pageKind,
capabilities,
recommendedTemplate: template.name,
confidence: template.confidence,
reasons: template.reasons,
warnings: buildWarnings(pageKind, template.name, spec, dataModel, apiMatches, rowActions),
dataModel,
rowActions
}
}
function inferPageKind(route, spec, apiMatches) {
const path = route.prototype ?? spec.page
const text = contractText(spec)
if (['auth', 'auth-support'].includes(route.kind)) return route.targetRole ?? 'auth'
if (route.kind === 'reference') return 'reference'
if (route.kind === 'legacy-shell-reference') return 'layout-reference'
if (route.kind === 'prototype-navigation' || /choose|index/i.test(path) && /(挑選|選擇|導覽|返回雛型導覽)/.test(text)) return 'chooser'
if (/print|prt/i.test(path) || /(列印|申請表)/.test(text) && !hasAction(spec, ['edit', 'delete', 'save'])) return 'print'
if (hasAction(spec, ['edit', 'delete'])) return 'maintenance'
if (hasAction(spec, ['save', 'create'])) return 'application'
if (hasAction(spec, ['search']) || spec.pageContract.tables.some((table) => table.role === 'resultTable')) return 'query'
if (apiMatches.some((api) => ['PUT', 'DELETE'].includes(api.method))) return 'maintenance'
if (apiMatches.some((api) => api.method === 'POST')) return 'application'
return route.kind === 'feature-page' ? 'query' : 'reference'
}
function inferCapabilities(spec, apiMatches) {
const capabilities = new Set()
for (const action of spec.pageContract.actions) {
if (action.actionType && action.actionType !== 'custom') capabilities.add(action.actionType)
}
for (const api of apiMatches) {
if (api.method === 'GET' && /print-view|print/i.test(api.path)) capabilities.add('print')
else if (api.method === 'GET') capabilities.add('search')
else if (api.method === 'POST' && /availability/i.test(api.path)) capabilities.add('availabilityCheck')
else if (api.method === 'POST') capabilities.add('create')
else if (api.method === 'PUT' || api.method === 'PATCH') capabilities.add('edit')
else if (api.method === 'DELETE') capabilities.add('delete')
}
return [...capabilities]
}
function inferDataModel(route, spec, apiMatches) {
const primaryEntity = inferPrimaryEntity(route, apiMatches)
const detailEntities = []
const fieldNames = spec.pageContract.forms.flatMap((form) => form.fields.map((field) => field.name ?? field.label)).filter(Boolean)
const collectionFields = fieldNames.filter((name) => /s$|list|items|details|明細|志願|節次|設備|場地/i.test(name))
for (const field of collectionFields) {
detailEntities.push({ name: entityName(field), source: 'field' })
}
return {
primaryEntity,
detailEntities: dedupeByName(detailEntities),
fields: fieldNames,
searchFields: spec.pageContract.forms.flatMap((form) => form.fields).filter((field) => field.sourceTable && spec.pageContract.tables.find((table) => table.id === field.sourceTable)?.role === 'searchTable'),
formFields: spec.pageContract.forms.flatMap((form) => form.fields).filter((field) => !field.sourceTable || spec.pageContract.tables.find((table) => table.id === field.sourceTable)?.role !== 'searchTable'),
tableColumns: spec.pageContract.tables.flatMap((table) => table.headers.map((header) => ({ tableId: table.id, role: table.role, label: header }))),
detailCollections: dedupeByName(detailEntities)
}
}
function inferPrimaryEntity(route, apiMatches) {
const targetView = route.guide?.targetView?.replace(/View\.vue$/i, '').replace(/\.vue$/i, '')
if (targetView) return targetView
const strongest = apiMatches[0]
if (strongest) {
const parts = strongest.path.split('/').filter((part) => part && !part.startsWith('{') && !part.startsWith('_'))
return entityName(parts.at(-1) ?? parts.at(-2) ?? 'Record')
}
return entityName((route.page ?? route.prototype ?? 'Record').replace(/\.html$/i, ''))
}
function recommendTemplate(pageKind, spec, capabilities, dataModel) {
if (!['maintenance', 'application'].includes(pageKind)) {
return { name: 'none', confidence: 0.9, reasons: [`pageKind=${pageKind} 不套維護頁 CRUD 範本`] }
}
if (hasEditableResultTable(spec)) {
return { name: 'editable-grid', confidence: 0.75, reasons: ['結果表格內含可編輯欄位'] }
}
if (dataModel.detailEntities.length > 1 || hasGroupedDetails(spec)) {
return { name: 'master-detail-b', confidence: 0.65, reasons: ['偵測到多組明細集合或群組結構'] }
}
if (dataModel.detailEntities.length === 1 || hasDetailEvidence(spec)) {
return { name: 'master-detail-c', confidence: 0.7, reasons: ['主檔表單搭配簡單明細集合'] }
}
if (capabilities.includes('edit') || capabilities.includes('delete') || capabilities.includes('create')) {
return { name: 'single-record', confidence: 0.7, reasons: ['具備查詢列表與單筆 CRUD 操作'] }
}
return { name: 'none', confidence: 0.5, reasons: ['沒有足夠維護頁操作證據'] }
}
function buildWarnings(pageKind, recommendedTemplate, spec, dataModel, apiMatches, rowActions = []) {
const warnings = []
if (pageKind === 'maintenance' && !hasAction(spec, ['search']) && spec.pageContract.tables.length === 0) {
warnings.push('maintenance 頁缺少查詢 action 或表格 evidence')
}
if (recommendedTemplate !== 'none' && !dataModel.primaryEntity) {
warnings.push('建議維護頁範本但缺少 primaryEntity')
}
if (hasAction(spec, ['edit', 'delete']) && apiMatches.length === 0) {
warnings.push('有 edit/delete action 但沒有匹配 API endpoint')
}
for (const action of rowActions) {
if (action.enabledWhen === null && action.disabled) warnings.push(`row action ${action.label} 有狀態限制但未產出 enabledWhen`)
}
return warnings
}
function inferRowActions(spec) {
const guideText = (spec.prototypeGuide?.checklist ?? []).join(' ')
return spec.pageContract.actions
.filter((action) => action.scope === 'rowAction')
.map((action) => ({
label: action.label,
actionType: action.actionType,
disabled: action.disabled,
enabledWhen: inferEnabledWhen(`${action.label} ${guideText}`)
}))
}
function inferEnabledWhen(text) {
const aprv = text.match(/aprvYn\s*(?:===|==|=|為|是)\s*['"]?([A-Z0-9])['"]?/i)
if (aprv) return `aprvYn === '${aprv[1]}'`
const quoted = text.match(/`([^`]+)`\s*才(?:能|可)/)
if (quoted) return quoted[1]
return null
}
function hasEditableResultTable(spec) {
return spec.pageContract.tables.some((table) => table.role === 'resultTable' && table.sampleRows.flat().some((cell) => /<input|<select|<textarea/i.test(cell)))
}
function hasGroupedDetails(spec) {
const text = contractText(spec)
return /(群組|accordion|collapse|多組|階層)/i.test(text)
}
function hasDetailEvidence(spec) {
return spec.pageContract.tables.some((table) => table.role === 'detailTable') ||
spec.pageContract.forms.some((form) => form.fields.some((field) => /明細|志願|節次|設備|場地|items|details/i.test(`${field.name ?? ''} ${field.label ?? ''}`)))
}
function hasAction(spec, actionTypes) {
return spec.pageContract.actions.some((action) => actionTypes.includes(action.actionType))
}
function contractText(spec) {
return [
spec.pageContract.title,
...spec.pageContract.textSamples,
...spec.pageContract.actions.map((action) => action.label),
...spec.pageContract.tables.flatMap((table) => [...table.headers, ...table.sampleRows.flat()])
].filter(Boolean).join(' ')
}
function entityName(value) {
const parts = String(value)
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/[^a-z0-9\u4e00-\u9fff]+/gi, ' ')
.trim()
.split(/\s+/)
.filter(Boolean)
if (parts.length === 0) return 'Record'
return parts.map((part) => part[0].toUpperCase() + part.slice(1)).join('')
}
function dedupeByName(items) {
const seen = new Set()
return items.filter((item) => {
if (seen.has(item.name)) return false
seen.add(item.name)
return true
})
}