feat: 參考backend
This commit is contained in:
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user