恢复点(restore point)。别人改崩后可 git reset --hard 回到此提交。 == 此快照内容 == - 后端 oa-backend: 734 控制器 / 711 实体 (Spring Boot 3.2.5 + SQLite, 端口8091) - 前端 modern-ui/app: Vue3+Vite, 约700页 (构建产物已在 oa-backend/src/main/resources/static) - 数据库 oa-backend/data/oa.db: 含全部演示数据 (强制入库, 6.6MB) - 交接文档 go.md + go-code-reference/endpoints/entities/database.md - 多代理建设脚本 .claude/wf-*.js == 状态 == - 对 凯迪科技ERP_20260507.xlsx 合规 MET ~73.3% (PARTIAL 75: 34可建+6种子/bug+35外部硬天花板) - 安全: 5轮红队+5轮复检, default-deny分级鉴权, 连续零可利用 - W3~W7 累计补完436缺口; W8末轮(40缺口)为半成品(源码树可编译但未集成) - 运行: cd oa-backend; java -jar build/libs/oa-backend-0.1.0.jar --server.port=8091; admin/123456 == 排除(gitignore, 可再生) == node_modules / oa-backend/build / .jdks / *.log / Backup-ERP-* / 弃用的OFBiz核心(只保留modern-ui) 完整文件夹备份见同目录 Backup-ERP-20260615-191517/ (含上述全部, 仅缺 node_modules) 时间戳: 20260615-191517 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
278 lines
9.6 KiB
TypeScript
278 lines
9.6 KiB
TypeScript
import { reactive } from 'vue'
|
||
import { getEntityOptions } from '../../services/api'
|
||
import type { OptionSource } from '../../types/api'
|
||
|
||
export const GENERATED_FIELD_WIDGETS = [
|
||
'display',
|
||
'drop-down',
|
||
'text',
|
||
'hidden',
|
||
'date-time',
|
||
'submit',
|
||
'lookup',
|
||
'hyperlink',
|
||
'ignored',
|
||
'unknown',
|
||
'textarea',
|
||
'check',
|
||
'file',
|
||
'radio',
|
||
'password'
|
||
] as const
|
||
|
||
export type SelectOption = {
|
||
label: string
|
||
value: unknown
|
||
source?: string
|
||
}
|
||
|
||
export type SelectOptionState = {
|
||
loading: boolean
|
||
reason: string
|
||
loaded: boolean
|
||
options: SelectOption[]
|
||
}
|
||
|
||
const controlOnlyWidgets = new Set(['hidden', 'ignored', 'submit'])
|
||
const readonlyWidgets = new Set(['display', 'display-entity', 'label', 'readonly', 'read-only'])
|
||
const hyperlinkWidgets = new Set(['hyperlink', 'link'])
|
||
const selectWidgets = new Set(['drop-down', 'select', 'combo-box', 'multi-select'])
|
||
const radioWidgets = new Set(['radio'])
|
||
const checkboxWidgets = new Set(['check', 'checkbox', 'boolean'])
|
||
const lookupWidgets = new Set(['lookup'])
|
||
const uploadWidgets = new Set(['file', 'upload', 'image', 'binary'])
|
||
const textareaWidgets = new Set(['textarea'])
|
||
const passwordWidgets = new Set(['password'])
|
||
|
||
function cleanFieldText(value: unknown) {
|
||
return String(value || '')
|
||
.replace(/\$\{uiLabelMap\.([^}]+)\}/g, '$1')
|
||
.replace(/\$\{([^}]+)\}/g, '$1')
|
||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||
.replace(/[-_]+/g, ' ')
|
||
.trim()
|
||
}
|
||
|
||
export function fieldName(field: Record<string, unknown>) {
|
||
return String(field.name || field.fieldName || field.id || field.parameterName || field.attributeName || '')
|
||
}
|
||
|
||
export function labelFor(field: Record<string, unknown>) {
|
||
return cleanFieldText(field.title || field.label || field.description || field.name || field.fieldName || field.id) || '字段'
|
||
}
|
||
|
||
export function fieldWidget(field: Record<string, unknown>) {
|
||
return String(field.widget || field.type || field.fieldType || 'text').trim().toLowerCase()
|
||
}
|
||
|
||
function fieldHaystack(field: Record<string, unknown>) {
|
||
return [
|
||
fieldWidget(field),
|
||
field.name,
|
||
field.fieldName,
|
||
field.id,
|
||
field.title,
|
||
field.label,
|
||
field.description,
|
||
field.tooltip,
|
||
field.target
|
||
].map((item) => String(item || '').toLowerCase()).join(' ')
|
||
}
|
||
|
||
export function isControlField(field: Record<string, unknown>) {
|
||
return controlOnlyWidgets.has(fieldWidget(field))
|
||
}
|
||
|
||
export function isReadonlyWidget(field: Record<string, unknown>) {
|
||
return readonlyWidgets.has(fieldWidget(field))
|
||
}
|
||
|
||
export function isHyperlinkWidget(field: Record<string, unknown>) {
|
||
const widget = fieldWidget(field)
|
||
if (hyperlinkWidgets.has(widget)) return true
|
||
return widget === 'unknown' && Boolean(field.target) && /link|url|uri|website/.test(fieldHaystack(field))
|
||
}
|
||
|
||
export function isSelectWidget(field: Record<string, unknown>) {
|
||
if (isRadioWidget(field) || isCheckboxWidget(field)) return false
|
||
return selectWidgets.has(fieldWidget(field))
|
||
|| Array.isArray(field.options) && field.options.length > 0
|
||
|| Array.isArray(field.optionSources) && field.optionSources.length > 0
|
||
}
|
||
|
||
export function isRadioWidget(field: Record<string, unknown>) {
|
||
return radioWidgets.has(fieldWidget(field))
|
||
}
|
||
|
||
export function isMultiSelectWidget(field: Record<string, unknown>) {
|
||
return fieldWidget(field) === 'multi-select'
|
||
}
|
||
|
||
export function isCheckboxWidget(field: Record<string, unknown>) {
|
||
return checkboxWidgets.has(fieldWidget(field))
|
||
}
|
||
|
||
export function isLookupWidget(field: Record<string, unknown>) {
|
||
return lookupWidgets.has(fieldWidget(field))
|
||
}
|
||
|
||
export function isUploadWidget(field: Record<string, unknown>) {
|
||
const haystack = fieldHaystack(field)
|
||
return uploadWidgets.has(fieldWidget(field))
|
||
|| /upload|file|binary|image|logo|picture|photo|avatar|vcard/.test(haystack)
|
||
}
|
||
|
||
export function isDateWidget(field: Record<string, unknown>) {
|
||
const haystack = fieldHaystack(field)
|
||
return fieldWidget(field).includes('date')
|
||
|| fieldWidget(field).includes('time')
|
||
|| /date|time|timestamp/.test(haystack)
|
||
}
|
||
|
||
export function datePickerTypeFor(field: Record<string, unknown>) {
|
||
const haystack = fieldHaystack(field)
|
||
return haystack.includes('time') || haystack.includes('timestamp') ? 'datetime' : 'date'
|
||
}
|
||
|
||
export function isTextareaWidget(field: Record<string, unknown>) {
|
||
return textareaWidgets.has(fieldWidget(field))
|
||
|| /description|comment|note|content|message|body|remark|memo/.test(fieldHaystack(field))
|
||
}
|
||
|
||
export function isPasswordWidget(field: Record<string, unknown>) {
|
||
return passwordWidgets.has(fieldWidget(field))
|
||
}
|
||
|
||
export function isNumericWidget(field: Record<string, unknown>) {
|
||
const haystack = fieldHaystack(field)
|
||
return ['number', 'integer', 'decimal', 'currency'].includes(fieldWidget(field))
|
||
|| /amount|balance|cost|price|total|quantity|count|limit|sequence|percent|rate|coeff|debit|credit|weight|height|width/.test(haystack)
|
||
|| /\bnum\b/.test(haystack)
|
||
}
|
||
|
||
export function inputTypeFor(field: Record<string, unknown>) {
|
||
const haystack = fieldHaystack(field)
|
||
if (/email|fromaddress|toaddress/.test(haystack)) return 'email'
|
||
if (/url|uri|website/.test(haystack)) return 'url'
|
||
if (/phone|telecom|tel/.test(haystack)) return 'tel'
|
||
return 'text'
|
||
}
|
||
|
||
export function shouldLoadOptions(field: Record<string, unknown>) {
|
||
return isSelectWidget(field) || isRadioWidget(field)
|
||
}
|
||
|
||
export function fieldValueText(value: unknown) {
|
||
if (value === undefined || value === null || value === '') return '-'
|
||
if (typeof value === 'boolean') return value ? '是' : '否'
|
||
if (Array.isArray(value)) return value.length ? `${value.length} 项` : '-'
|
||
if (typeof value === 'object') {
|
||
const entries = Object.entries(value as Record<string, unknown>)
|
||
.filter(([, entryValue]) => entryValue !== undefined && entryValue !== null && entryValue !== '' && typeof entryValue !== 'object')
|
||
.slice(0, 2)
|
||
if (!entries.length) return '已提供'
|
||
return entries.map(([key, entryValue]) => `${cleanFieldText(key)}: ${entryValue}`).join(',')
|
||
}
|
||
return String(value)
|
||
}
|
||
|
||
export function staticOptionsFor(field: Record<string, unknown>): SelectOption[] {
|
||
const options = Array.isArray(field.options) ? field.options : []
|
||
return options
|
||
.map((option) => {
|
||
if (typeof option === 'string' || typeof option === 'number' || typeof option === 'boolean') {
|
||
return { label: cleanFieldText(option), value: option }
|
||
}
|
||
if (!option || typeof option !== 'object') return null
|
||
const item = option as Record<string, unknown>
|
||
const value = Object.prototype.hasOwnProperty.call(item, 'value')
|
||
? item.value
|
||
: item.key || item.id || item.name
|
||
const label = cleanFieldText(item.label || item.description || item.text || item.name || value)
|
||
return { label, value }
|
||
})
|
||
.filter((option): option is SelectOption => Boolean(option))
|
||
}
|
||
|
||
export function optionSourcesFor(field: Record<string, unknown>): OptionSource[] {
|
||
const sources: OptionSource[] = Array.isArray(field.optionSources) ? field.optionSources as OptionSource[] : []
|
||
if (field.optionSource && typeof field.optionSource === 'object') {
|
||
sources.push(field.optionSource as OptionSource)
|
||
}
|
||
const entityName = field.optionEntityName || field.entityName
|
||
if (entityName) {
|
||
sources.push({
|
||
type: 'entity-options',
|
||
entityName: String(entityName),
|
||
keyFieldName: String(field.keyFieldName || field.valueField || field.keyField || fieldName(field)),
|
||
description: String(field.descriptionFields || field.labelField || field.descriptionField || 'description')
|
||
})
|
||
}
|
||
return sources
|
||
}
|
||
|
||
export function firstEntityOptionSource(field: Record<string, unknown>) {
|
||
return optionSourcesFor(field).find((source) => source.type === 'entity-options' && source.entityName)
|
||
}
|
||
|
||
export function optionSourceReason(field: Record<string, unknown>, state?: SelectOptionState) {
|
||
if (state?.reason) return state.reason
|
||
const listSource = optionSourcesFor(field).find((source) => source.type === 'list-options')
|
||
if (listSource) {
|
||
return `动态列表 ${listSource.listName || fieldName(field)} 需要进入原业务上下文后加载`
|
||
}
|
||
const entitySource = firstEntityOptionSource(field)
|
||
if (entitySource) {
|
||
return '动态选项来自业务资料'
|
||
}
|
||
return ''
|
||
}
|
||
|
||
export function createOptionState() {
|
||
return reactive<Record<string, SelectOptionState>>({})
|
||
}
|
||
|
||
export async function ensureFieldOptions(
|
||
field: Record<string, unknown>,
|
||
states: Record<string, SelectOptionState>,
|
||
query = ''
|
||
) {
|
||
const name = fieldName(field)
|
||
const source = firstEntityOptionSource(field)
|
||
if (!name || !source) return
|
||
const state = states[name] || (states[name] = {
|
||
loading: false,
|
||
reason: '',
|
||
loaded: false,
|
||
options: []
|
||
})
|
||
if (state.loading) return
|
||
state.loading = true
|
||
state.reason = ''
|
||
try {
|
||
const result = await getEntityOptions(source, { query, pageSize: 60 })
|
||
state.options = Array.isArray(result.options)
|
||
? result.options.map((option) => ({ label: String(option.label || option.value || ''), value: option.value, source: 'entity-options' }))
|
||
: []
|
||
state.loaded = !result.unavailable
|
||
state.reason = result.unavailable ? String(result.reason || '动态选项暂不可用') : ''
|
||
} catch (error) {
|
||
state.loaded = false
|
||
state.reason = error instanceof Error ? error.message : '动态选项加载失败'
|
||
} finally {
|
||
state.loading = false
|
||
}
|
||
}
|
||
|
||
export function mergedOptionsFor(field: Record<string, unknown>, states: Record<string, SelectOptionState>) {
|
||
const seen = new Set<string>()
|
||
const merged: SelectOption[] = []
|
||
for (const option of [...staticOptionsFor(field), ...(states[fieldName(field)]?.options || [])]) {
|
||
const key = String(option.value)
|
||
if (seen.has(key)) continue
|
||
seen.add(key)
|
||
merged.push(option)
|
||
}
|
||
return merged
|
||
}
|