SNAPSHOT W7 已部署稳定态 — 凯迪ERP+OA一体化平台 (MET 73.3%)

恢复点(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>
This commit is contained in:
Qiufeng
2026-06-15 19:19:15 +08:00
co-authored by Claude Opus 4.8
commit 5e51dc3f56
10584 changed files with 2501339 additions and 0 deletions
@@ -0,0 +1,484 @@
<script setup lang="ts">
/**
* 文件多格式在线真渲染。给定 StoredFile 的 id(或直接给 url),fetch 其字节流后
* 按 MIME / 扩展名分形态真实渲染:
* - PDF → pdfjs-dist 逐页 render 到 <canvas>
* - docx → mammoth.convertToHtml → v-html
* - xlsx/xls/csv → SheetJS(XLSX) 读取 → 每个 sheet sheet_to_html → 表格(可切 sheet
* - image/* → blob URL → <img>
* - video/* → blob URL → <video controls>
* - 其它 → 提示暂不支持在线预览,给下载链接
* 含 loading 态与错误态。图标仅用 @element-plus/icons-vue。
*/
import { ref, watch, onBeforeUnmount, computed, nextTick } from 'vue'
import { Loading, WarningFilled, Download, Document } from '@element-plus/icons-vue'
import * as pdfjsLib from 'pdfjs-dist'
import mammoth from 'mammoth'
import * as XLSX from 'xlsx'
import { fetchFileBytes, fileStreamUrl } from '../oa/api/files'
const props = defineProps<{
/** StoredFile.id —— 优先使用,组件自行取流。 */
fileId?: number | null
/** 直接给定的取流 URL(与 fileId 二选一)。 */
url?: string
/** 文件名(用于扩展名推断、下载名、标题)。 */
name?: string
/** 后端返回的 MIME(可选,缺省时回退按扩展名判断)。 */
contentType?: string
/** 受控水印文字(可选)。给定则叠加旋转水印层。 */
watermark?: string
}>()
type Kind = 'pdf' | 'docx' | 'sheet' | 'image' | 'video' | 'text' | 'unsupported'
const loading = ref(false)
const error = ref('')
const kind = ref<Kind>('unsupported')
// 渲染产物
const pdfCanvasHost = ref<HTMLDivElement | null>(null)
const docxHtml = ref('')
const sheets = ref<{ name: string; html: string }[]>([])
const activeSheet = ref(0)
const blobUrl = ref('') // image / video
const textContent = ref('')
const resolvedName = computed(() => props.name || '文件')
// 取消串扰:每次加载自增,过期的异步结果直接丢弃。
let loadSeq = 0
let activeAbort: AbortController | null = null
const ext = computed(() => {
const n = resolvedName.value.toLowerCase()
const dot = n.lastIndexOf('.')
return dot >= 0 ? n.slice(dot + 1) : ''
})
const downloadUrl = computed(() => {
if (props.url) return props.url
if (props.fileId != null) return fileStreamUrl(props.fileId)
return ''
})
function detectKind(contentType: string, e: string): Kind {
const ct = (contentType || '').toLowerCase()
if (ct.includes('pdf') || e === 'pdf') return 'pdf'
if (ct.startsWith('image/') || ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg'].includes(e)) return 'image'
if (ct.startsWith('video/') || ['mp4', 'webm', 'ogg', 'mov'].includes(e)) return 'video'
if (ct.includes('wordprocessingml') || e === 'docx') return 'docx'
if (
ct.includes('spreadsheetml') ||
ct.includes('ms-excel') ||
ct.includes('text/csv') ||
['xlsx', 'xls', 'csv'].includes(e)
)
return 'sheet'
if (ct.startsWith('text/') || ['txt', 'md', 'json', 'log', 'xml'].includes(e)) return 'text'
return 'unsupported'
}
function cleanup() {
if (blobUrl.value) {
URL.revokeObjectURL(blobUrl.value)
blobUrl.value = ''
}
docxHtml.value = ''
sheets.value = []
activeSheet.value = 0
textContent.value = ''
if (pdfCanvasHost.value) pdfCanvasHost.value.innerHTML = ''
}
async function load() {
const seq = ++loadSeq
activeAbort?.abort()
const abort = new AbortController()
activeAbort = abort
cleanup()
error.value = ''
if (props.fileId == null && !props.url) {
kind.value = 'unsupported'
return
}
loading.value = true
try {
let buffer: ArrayBuffer
let ct = props.contentType || ''
if (props.fileId != null) {
const fetched = await fetchFileBytes(props.fileId, abort.signal)
buffer = fetched.buffer
ct = props.contentType || fetched.contentType
} else {
const res = await fetch(props.url as string, { signal: abort.signal })
if (!res.ok) throw new Error(`取文件流失败 (HTTP ${res.status})`)
buffer = await res.arrayBuffer()
ct = props.contentType || (res.headers.get('Content-Type') || '').split(';')[0].trim()
}
if (seq !== loadSeq) return // 已被新的加载取代
const k = detectKind(ct, ext.value)
kind.value = k
if (k === 'pdf') {
await renderPdf(buffer, seq)
} else if (k === 'docx') {
const out = await mammoth.convertToHtml({ arrayBuffer: buffer })
if (seq !== loadSeq) return
docxHtml.value = out.value || '<p style="color:var(--erp-color-text-subtle)">(空文档)</p>'
} else if (k === 'sheet') {
renderSheet(buffer, ct, seq)
} else if (k === 'image' || k === 'video') {
const blob = new Blob([buffer], { type: ct || undefined })
if (seq !== loadSeq) return
blobUrl.value = URL.createObjectURL(blob)
} else if (k === 'text') {
const text = new TextDecoder('utf-8').decode(new Uint8Array(buffer))
if (seq !== loadSeq) return
textContent.value = text.length > 200000 ? text.slice(0, 200000) + '\n…(已截断)' : text
}
} catch (e) {
if ((e as Error)?.name === 'AbortError') return
if (seq === loadSeq) error.value = (e as Error)?.message || '预览失败'
} finally {
if (seq === loadSeq) loading.value = false
}
}
async function renderPdf(buffer: ArrayBuffer, seq: number) {
// copypdfjs 可能 transfer/detach 传入的 buffer,复制后图片等其它分支不受影响。
const data = buffer.slice(0)
const task = pdfjsLib.getDocument({ data: new Uint8Array(data) })
const pdf = await task.promise
if (seq !== loadSeq) {
task.destroy()
return
}
await nextTick()
const host = pdfCanvasHost.value
if (!host) {
task.destroy()
return
}
host.innerHTML = ''
const maxPages = Math.min(pdf.numPages, 50) // 上限保护超长 PDF
for (let i = 1; i <= maxPages; i++) {
if (seq !== loadSeq) break
const page = await pdf.getPage(i)
const viewport = page.getViewport({ scale: 1.3 })
const canvas = document.createElement('canvas')
canvas.className = 'fp-pdf__page'
canvas.width = Math.ceil(viewport.width)
canvas.height = Math.ceil(viewport.height)
const ctx = canvas.getContext('2d')
if (!ctx) continue
host.appendChild(canvas)
await page.render({ canvas, canvasContext: ctx, viewport }).promise
page.cleanup()
}
if (seq === loadSeq && pdf.numPages > maxPages) {
const note = document.createElement('div')
note.className = 'fp-pdf__more'
note.textContent = `仅渲染前 ${maxPages} 页,共 ${pdf.numPages} 页,余下请下载查看`
host.appendChild(note)
}
}
function renderSheet(buffer: ArrayBuffer, ct: string, seq: number) {
// csv 也走 XLSX.readarray 类型自动识别。
const wb = XLSX.read(new Uint8Array(buffer), { type: 'array' })
if (seq !== loadSeq) return
sheets.value = wb.SheetNames.map((sn) => ({
name: sn,
html: XLSX.utils.sheet_to_html(wb.Sheets[sn], { id: '' })
}))
activeSheet.value = 0
}
watch(
() => [props.fileId, props.url, props.contentType, props.name],
() => load(),
{ immediate: true }
)
onBeforeUnmount(() => {
activeAbort?.abort()
cleanup()
})
</script>
<template>
<div class="fp">
<div v-if="loading" class="fp__state">
<el-icon class="fp__spin"><Loading /></el-icon>
<span>正在加载预览</span>
</div>
<div v-else-if="error" class="fp__state fp__state--error">
<el-icon><WarningFilled /></el-icon>
<span>预览失败{{ error }}</span>
<a v-if="downloadUrl" :href="downloadUrl" :download="resolvedName" class="fp__dl">
<el-icon><Download /></el-icon> 下载原文件
</a>
</div>
<template v-else>
<div class="fp__body" :class="{ 'fp__body--wm': !!watermark }">
<div v-if="watermark" class="fp__wm" :style="{ '--fp-wm': `'${watermark}'` }"></div>
<!-- PDF -->
<div v-if="kind === 'pdf'" ref="pdfCanvasHost" class="fp-pdf"></div>
<!-- docx -->
<div v-else-if="kind === 'docx'" class="fp-doc" v-html="docxHtml"></div>
<!-- xlsx / xls / csv -->
<div v-else-if="kind === 'sheet'" class="fp-sheet">
<div v-if="sheets.length > 1" class="fp-sheet__tabs">
<button
v-for="(s, i) in sheets"
:key="s.name + i"
class="fp-sheet__tab"
:class="{ 'is-active': activeSheet === i }"
@click="activeSheet = i"
>
{{ s.name }}
</button>
</div>
<div class="fp-sheet__table" v-html="sheets[activeSheet]?.html"></div>
</div>
<!-- 图片 -->
<div v-else-if="kind === 'image'" class="fp-img">
<img :src="blobUrl" :alt="resolvedName" />
</div>
<!-- 视频 -->
<div v-else-if="kind === 'video'" class="fp-video">
<video :src="blobUrl" controls preload="metadata"></video>
</div>
<!-- 纯文本 -->
<pre v-else-if="kind === 'text'" class="fp-text">{{ textContent }}</pre>
<!-- 不支持 -->
<div v-else class="fp__state fp__unsupported">
<el-icon class="fp__unsupported-icon"><Document /></el-icon>
<p>该格式暂不支持在线预览</p>
<a v-if="downloadUrl" :href="downloadUrl" :download="resolvedName" class="fp__dl">
<el-icon><Download /></el-icon> 下载文件
</a>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.fp {
position: relative;
width: 100%;
min-height: 160px;
}
.fp__state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--erp-space-2);
min-height: 200px;
color: var(--erp-color-text-subtle);
font-size: var(--erp-font-size-sm);
}
.fp__state--error {
color: var(--erp-color-danger);
}
.fp__spin {
font-size: 28px;
animation: fp-spin 1s linear infinite;
}
@keyframes fp-spin {
to {
transform: rotate(360deg);
}
}
.fp__dl {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--erp-color-primary);
text-decoration: none;
font-size: var(--erp-font-size-sm);
}
.fp__dl:hover {
text-decoration: underline;
}
.fp__body {
position: relative;
width: 100%;
}
/* 受控水印层 */
.fp__wm {
position: absolute;
inset: 0;
z-index: 5;
pointer-events: none;
overflow: hidden;
}
.fp__wm::after {
content: var(--fp-wm);
position: absolute;
inset: -40%;
display: grid;
place-content: center;
gap: 80px;
transform: rotate(-24deg);
font-size: 15px;
letter-spacing: 2px;
color: var(--erp-color-text-subtle);
opacity: 0.16;
white-space: pre;
line-height: 120px;
}
/* PDF */
.fp-pdf {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--erp-space-3);
background: var(--erp-color-surface-quiet);
padding: var(--erp-space-3);
border-radius: var(--erp-radius-md);
}
.fp-pdf :deep(.fp-pdf__page) {
max-width: 100%;
height: auto;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
background: #fff;
}
.fp-pdf :deep(.fp-pdf__more) {
font-size: var(--erp-font-size-xs);
color: var(--erp-color-text-subtle);
}
/* docx */
.fp-doc {
background: #fff;
color: #222;
padding: var(--erp-space-5) var(--erp-space-6);
border: 1px solid var(--erp-color-border-soft);
border-radius: var(--erp-radius-md);
line-height: 1.8;
font-size: var(--erp-font-size-sm);
overflow-x: auto;
}
.fp-doc :deep(table) {
border-collapse: collapse;
margin: var(--erp-space-2) 0;
}
.fp-doc :deep(td),
.fp-doc :deep(th) {
border: 1px solid #ccc;
padding: 4px 8px;
}
.fp-doc :deep(img) {
max-width: 100%;
height: auto;
}
.fp-doc :deep(h1),
.fp-doc :deep(h2),
.fp-doc :deep(h3) {
margin: 0.8em 0 0.4em;
}
/* sheet */
.fp-sheet__tabs {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: var(--erp-space-2);
}
.fp-sheet__tab {
border: 1px solid var(--erp-color-border-soft);
background: var(--erp-color-surface);
color: var(--erp-color-text-muted);
border-radius: var(--erp-radius-sm);
padding: 2px 12px;
font-size: var(--erp-font-size-xs);
cursor: pointer;
}
.fp-sheet__tab.is-active {
background: var(--erp-color-primary-soft);
color: var(--erp-color-primary);
border-color: var(--erp-color-primary);
font-weight: 600;
}
.fp-sheet__table {
overflow: auto;
border: 1px solid var(--erp-color-border-soft);
border-radius: var(--erp-radius-md);
background: #fff;
}
.fp-sheet__table :deep(table) {
border-collapse: collapse;
width: 100%;
font-size: var(--erp-font-size-sm);
color: #222;
}
.fp-sheet__table :deep(td),
.fp-sheet__table :deep(th) {
border: 1px solid var(--erp-color-border-soft);
padding: 5px 10px;
white-space: nowrap;
}
.fp-sheet__table :deep(tr:first-child td) {
background: var(--erp-color-neutral-soft);
font-weight: 600;
}
/* image / video */
.fp-img,
.fp-video {
display: flex;
justify-content: center;
align-items: center;
background: var(--erp-color-surface-quiet);
border-radius: var(--erp-radius-md);
padding: var(--erp-space-3);
}
.fp-img img,
.fp-video video {
max-width: 100%;
max-height: 70vh;
border-radius: var(--erp-radius-sm);
}
/* text */
.fp-text {
margin: 0;
background: #fff;
color: #222;
padding: var(--erp-space-4);
border: 1px solid var(--erp-color-border-soft);
border-radius: var(--erp-radius-md);
font-size: var(--erp-font-size-sm);
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
max-height: 70vh;
overflow: auto;
}
.fp__unsupported {
border: 1px dashed var(--erp-color-border);
border-radius: var(--erp-radius-md);
background: var(--erp-color-surface-quiet);
}
.fp__unsupported-icon {
font-size: 40px;
color: var(--erp-color-text-subtle);
}
</style>
@@ -0,0 +1,350 @@
<script setup lang="ts">
/**
* 通用富文本编辑器(基于 tiptap)。
*
* v-model 双向绑定 HTML 字符串:
* - 本地编辑(onUpdate-> emit('update:modelValue', editor.getHTML())。
* - 外部改 modelValue -> watch 里 setContent,仅当与当前 HTML 不同才写,且
* 用 { emitUpdate:false } 避免回环;setContent 不抢焦点(不调用 focus)。
*
* 扩展:StarterKit(含加粗/斜体/标题/列表/引用/历史等)+ Link + Image。
* readonly 时隐藏工具条、editor.setEditable(false),只读渲染正文。
*
* 图标全部来自 @element-plus/icons-vue,不使用任何 emoji。
*/
import { onBeforeUnmount, watch } from 'vue'
import { useEditor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
import Image from '@tiptap/extension-image'
import { ElMessageBox } from 'element-plus'
import {
Picture,
Link as LinkIcon,
List,
Sort,
ChatLineSquare,
Brush
} from '@element-plus/icons-vue'
const props = withDefaults(
defineProps<{
modelValue?: string
placeholder?: string
readonly?: boolean
}>(),
{ modelValue: '', placeholder: '请输入内容…', readonly: false }
)
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
const editor = useEditor({
content: props.modelValue || '',
editable: !props.readonly,
extensions: [
// StarterKit v3 自带 Link / Underline,用其内置选项配置(不要再单独引入
// @tiptap/extension-link,否则会出现 "Duplicate extension names: link" 警告)。
StarterKit.configure({
link: { openOnClick: false, autolink: true }
}),
Image.configure({ inline: false })
],
editorProps: {
attributes: {
class: 'rte-prosemirror',
// placeholder 通过 CSS:empty::before)展示,这里挂 data 供样式读取
'data-placeholder': props.placeholder
}
},
onUpdate: ({ editor }) => {
emit('update:modelValue', editor.getHTML())
}
})
// 外部 modelValue 变化(如打开弹窗回填、切换记录)-> 同步进编辑器,避免回环、不抢焦点。
watch(
() => props.modelValue,
(val) => {
const ed = editor.value
if (!ed) return
const next = val || ''
if (next === ed.getHTML()) return
ed.commands.setContent(next, { emitUpdate: false })
}
)
watch(
() => props.readonly,
(ro) => {
editor.value?.setEditable(!ro)
}
)
onBeforeUnmount(() => {
editor.value?.destroy()
})
// ---------- 工具条命令 ----------
function chain() {
return editor.value?.chain().focus()
}
function toggleBold() {
chain()?.toggleBold().run()
}
function toggleItalic() {
chain()?.toggleItalic().run()
}
function toggleUnderline() {
chain()?.toggleUnderline().run()
}
function toggleStrike() {
chain()?.toggleStrike().run()
}
function toggleH(level: 1 | 2) {
chain()?.toggleHeading({ level }).run()
}
function toggleBulletList() {
chain()?.toggleBulletList().run()
}
function toggleOrderedList() {
chain()?.toggleOrderedList().run()
}
function toggleBlockquote() {
chain()?.toggleBlockquote().run()
}
function clearFormat() {
chain()?.unsetAllMarks().clearNodes().run()
}
async function addLink() {
const ed = editor.value
if (!ed) return
const prev = ed.getAttributes('link').href as string | undefined
try {
const { value } = await ElMessageBox.prompt('请输入链接地址(留空可取消链接)', '插入链接', {
inputValue: prev || 'https://',
inputPlaceholder: 'https://example.com',
confirmButtonText: '确定',
cancelButtonText: '取消'
})
const url = (value || '').trim()
if (!url) {
ed.chain().focus().extendMarkRange('link').unsetLink().run()
return
}
ed.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
} catch {
// 用户取消,忽略
}
}
async function addImage() {
const ed = editor.value
if (!ed) return
try {
const { value } = await ElMessageBox.prompt('请输入图片地址(URL 或 base64 data:', '插入图片', {
inputPlaceholder: 'https://… 或 data:image/png;base64,…',
confirmButtonText: '插入',
cancelButtonText: '取消'
})
const src = (value || '').trim()
if (!src) return
ed.chain().focus().setImage({ src }).run()
} catch {
// 用户取消,忽略
}
}
function isActive(name: string, attrs?: Record<string, unknown>) {
return editor.value?.isActive(name, attrs) ?? false
}
</script>
<template>
<div class="rte" :class="{ 'rte--readonly': readonly }">
<div v-if="!readonly && editor" class="rte__toolbar">
<el-button-group>
<el-button size="small" :type="isActive('bold') ? 'primary' : 'default'" title="加粗" @click="toggleBold">
<span class="rte__bold">B</span>
</el-button>
<el-button size="small" :type="isActive('italic') ? 'primary' : 'default'" title="斜体" @click="toggleItalic">
<span class="rte__italic">I</span>
</el-button>
<el-button size="small" :type="isActive('underline') ? 'primary' : 'default'" title="下划线" @click="toggleUnderline">
<span class="rte__underline">U</span>
</el-button>
<el-button size="small" :type="isActive('strike') ? 'primary' : 'default'" title="删除线" @click="toggleStrike">
<span class="rte__strike">S</span>
</el-button>
</el-button-group>
<el-button-group>
<el-button size="small" :type="isActive('heading', { level: 1 }) ? 'primary' : 'default'" title="一级标题" @click="toggleH(1)">H1</el-button>
<el-button size="small" :type="isActive('heading', { level: 2 }) ? 'primary' : 'default'" title="二级标题" @click="toggleH(2)">H2</el-button>
</el-button-group>
<el-button-group>
<el-button size="small" :type="isActive('bulletList') ? 'primary' : 'default'" :icon="List" title="无序列表" @click="toggleBulletList" />
<el-button size="small" :type="isActive('orderedList') ? 'primary' : 'default'" :icon="Sort" title="有序列表" @click="toggleOrderedList" />
<el-button size="small" :type="isActive('blockquote') ? 'primary' : 'default'" :icon="ChatLineSquare" title="引用" @click="toggleBlockquote" />
</el-button-group>
<el-button-group>
<el-button size="small" :type="isActive('link') ? 'primary' : 'default'" :icon="LinkIcon" title="链接" @click="addLink" />
<el-button size="small" :icon="Picture" title="插入图片" @click="addImage" />
<el-button size="small" :icon="Brush" title="清除格式" @click="clearFormat" />
</el-button-group>
</div>
<editor-content :editor="editor" class="rte__content" />
</div>
</template>
<style scoped>
.rte {
border: 1px solid var(--erp-color-border-soft, #e8ebf1);
border-radius: var(--erp-radius-sm, 4px);
background: var(--erp-color-surface, #fff);
width: 100%;
box-sizing: border-box;
}
.rte--readonly {
border-color: transparent;
background: transparent;
}
.rte__toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--erp-space-2, 8px);
padding: 6px 8px;
border-bottom: 1px solid var(--erp-color-border-soft, #ebeef5);
}
.rte__toolbar :deep(.el-button-group .el-button) {
min-width: 30px;
padding-left: 8px;
padding-right: 8px;
font-weight: 600;
}
.rte__bold {
font-weight: 800;
}
.rte__italic {
font-style: italic;
font-weight: 600;
}
.rte__underline {
text-decoration: underline;
font-weight: 600;
}
.rte__strike {
text-decoration: line-through;
font-weight: 600;
}
.rte__content {
width: 100%;
}
/* ProseMirror 编辑区基础排版 */
.rte :deep(.rte-prosemirror) {
min-height: 200px;
padding: var(--erp-space-4, 16px);
outline: none;
color: var(--erp-color-text, #1d2433);
font-size: var(--erp-font-size-sm, 14px);
line-height: 1.8;
word-break: break-word;
}
.rte--readonly :deep(.rte-prosemirror) {
min-height: 0;
padding: 0;
}
/* 空内容占位 */
.rte :deep(.rte-prosemirror.is-editor-empty:first-child::before),
.rte :deep(.rte-prosemirror p.is-empty:first-child::before) {
content: attr(data-placeholder);
color: var(--erp-color-text-subtle, #6b7488);
float: left;
height: 0;
pointer-events: none;
}
.rte :deep(.rte-prosemirror h1) {
font-size: 1.6em;
font-weight: 700;
line-height: 1.4;
margin: 0.6em 0 0.4em;
}
.rte :deep(.rte-prosemirror h2) {
font-size: 1.3em;
font-weight: 700;
line-height: 1.4;
margin: 0.6em 0 0.4em;
}
.rte :deep(.rte-prosemirror p) {
margin: 0.4em 0;
}
.rte :deep(.rte-prosemirror ul),
.rte :deep(.rte-prosemirror ol) {
margin: 0.4em 0;
padding-left: 1.6em;
}
.rte :deep(.rte-prosemirror ul) {
list-style: disc;
}
.rte :deep(.rte-prosemirror ol) {
list-style: decimal;
}
.rte :deep(.rte-prosemirror li) {
margin: 0.2em 0;
}
.rte :deep(.rte-prosemirror blockquote) {
margin: 0.6em 0;
padding-left: 12px;
border-left: 3px solid var(--erp-color-border-soft, #e8ebf1);
color: var(--erp-color-text-subtle, #909399);
}
.rte :deep(.rte-prosemirror img) {
max-width: 100%;
height: auto;
border-radius: var(--erp-radius-sm, 4px);
}
.rte :deep(.rte-prosemirror a) {
color: var(--erp-color-primary, #409eff);
text-decoration: underline;
}
.rte :deep(.rte-prosemirror code) {
padding: 1px 4px;
border-radius: 3px;
background: var(--erp-color-fill, #f5f7fa);
font-family: var(--erp-font-mono, monospace);
font-size: 0.92em;
}
.rte :deep(.rte-prosemirror pre) {
margin: 0.6em 0;
padding: 10px 12px;
border-radius: var(--erp-radius-sm, 4px);
background: var(--erp-color-fill, #f5f7fa);
overflow: auto;
}
</style>
@@ -0,0 +1,185 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ElMessage } from 'element-plus'
import type { ActionDefinition } from '../../types/api'
import { runAction } from '../../services/api'
import { actionDisplayLabel } from '../../utils/display'
const props = defineProps<{
actions: Array<string | ActionDefinition>
compact?: boolean
payload?: Record<string, unknown>
}>()
const emit = defineEmits<{
executed: [result: Record<string, unknown>]
}>()
const runningAction = ref('')
function actionIdFor(action: string | ActionDefinition) {
if (typeof action === 'string') return action
return String(action.actionId || action.eventInvoke || action.serviceName || action.eventPath || action.label || '')
}
function labelFor(action: string | ActionDefinition) {
const raw = typeof action === 'string'
? action
: String(action.label || action.actionId || action.eventInvoke || action.serviceName || action.eventPath || 'Action')
return actionDisplayLabel(raw)
}
function reasonFor(action: string | ActionDefinition) {
if (typeof action === 'string') return ''
if (action.allowed === false) return String(action.reason || action.permission || '当前用户没有执行该业务动作的权限')
if (action.disabled) return String(action.reason || '此业务动作当前不可执行')
if (action.apiExecutable === false) return String(action.reason || '此业务动作需要进入专用处理页面')
return String(action.reason || '')
}
function isDisabled(action: string | ActionDefinition) {
if (!actionIdFor(action)) return true
if (typeof action === 'string') return false
return action.allowed === false || action.disabled === true || action.apiExecutable === false
}
const visibleActions = computed(() => props.actions.filter((action) => actionIdFor(action) || labelFor(action)))
const enabledActionCount = computed(() => visibleActions.value.filter((action) => !isDisabled(action)).length)
const totalActionCount = computed(() => visibleActions.value.length)
const primaryLimit = computed(() => props.compact ? 2 : 4)
const primaryActions = computed(() => visibleActions.value.slice(0, primaryLimit.value))
const overflowActions = computed(() => visibleActions.value.slice(primaryLimit.value))
const disabledReason = computed(() => {
const disabledAction = visibleActions.value.find((action) => isDisabled(action) && reasonFor(action))
return disabledAction ? reasonFor(disabledAction) : ''
})
function typeFor(action: string | ActionDefinition, index: number) {
const label = labelFor(action).toLowerCase()
if (
label.includes('delete')
|| label.includes('cancel')
|| label.includes('remove')
|| label.includes('reject')
|| label.includes('删除')
|| label.includes('取消')
|| label.includes('移除')
|| label.includes('拒绝')
) return 'danger'
if (index === 0) return 'primary'
return 'default'
}
async function execute(action: string | ActionDefinition) {
const actionId = actionIdFor(action)
const label = labelFor(action)
if (!actionId) {
const reason = `${label} 缺少动作编号`
ElMessage.warning(reason)
emit('executed', {
executed: false,
actionId: '',
label,
payload: props.payload || {},
reason,
source: 'action-bar'
})
return
}
if (isDisabled(action)) {
const reason = reasonFor(action)
ElMessage.warning(reason)
emit('executed', {
executed: false,
actionId,
label,
payload: props.payload || {},
reason,
source: 'action-bar'
})
return
}
runningAction.value = actionId
try {
const result = await runAction(actionId, props.payload || {})
emit('executed', {
...result,
actionId,
label,
source: 'action-bar'
})
if (result.executed === false) {
ElMessage.warning(String(result.reason || `${label} 暂不可执行`))
} else {
ElMessage.success(`${label} 已执行`)
}
} catch (error) {
const reason = error instanceof Error ? error.message : `${label} 执行失败`
emit('executed', {
executed: false,
actionId,
label,
payload: props.payload || {},
reason,
source: 'action-bar'
})
ElMessage.error(reason)
} finally {
runningAction.value = ''
}
}
</script>
<template>
<div class="modern-action-bar" :class="{ 'modern-action-bar--compact': compact }">
<el-tag
v-if="totalActionCount"
class="modern-action-bar__status"
:type="enabledActionCount ? 'success' : 'warning'"
effect="plain"
>
{{ enabledActionCount }} / {{ totalActionCount }} 可执行
</el-tag>
<el-alert
v-if="disabledReason"
class="modern-action-bar__notice"
type="warning"
:closable="false"
:description="disabledReason"
/>
<el-empty
v-if="!visibleActions.length"
class="modern-action-bar__empty"
description="当前没有可提交的业务动作"
:image-size="56"
/>
<el-button
v-for="(action, index) in primaryActions"
:key="actionIdFor(action) || labelFor(action)"
:data-modern-action-id="actionIdFor(action)"
:type="typeFor(action, index)"
:loading="runningAction === actionIdFor(action)"
:disabled="isDisabled(action)"
:title="reasonFor(action)"
@click="execute(action)"
>
{{ labelFor(action) }}
</el-button>
<el-dropdown v-if="overflowActions.length">
<el-button>更多</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="action in overflowActions"
:key="actionIdFor(action) || labelFor(action)"
:data-modern-action-id="actionIdFor(action)"
:disabled="isDisabled(action)"
@click="execute(action)"
>
{{ labelFor(action) }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
@@ -0,0 +1,129 @@
<script setup lang="ts">
/**
* 文章独立阅读页的展示组件(致远风格的长文阅读视图)。
* 用于新闻/公告/讨论/博客等「大段正文」内容——以居中、可读宽度的整页排版呈现,
* 取代原先把长文塞进右侧 520~640px 抽屉导致换行拥挤、不美观的做法。
*
* 仅负责展示:标题 + 分类 + 元信息(作者/时间/阅读数)+ 标签 + 正文 + 额外插槽(评论等)。
* 取数与路由跳转由承载页(pages/reader/article.vue)处理。
*/
import { ArrowLeft, Calendar, User, View as ViewIcon } from '@element-plus/icons-vue'
withDefaults(
defineProps<{
title?: string
category?: string
author?: string
time?: string
views?: number
tags?: string[]
body?: string
loading?: boolean
notFound?: boolean
backLabel?: string
}>(),
{ title: '', backLabel: '返回列表', tags: () => [] }
)
const emit = defineEmits<{ back: [] }>()
</script>
<template>
<div class="erp-reader">
<div class="erp-reader__bar">
<el-button text :icon="ArrowLeft" @click="emit('back')">{{ backLabel }}</el-button>
<el-tag v-if="category" size="small" effect="plain" type="info">{{ category }}</el-tag>
</div>
<div v-loading="loading" class="erp-reader__shell">
<article v-if="!notFound" class="erp-reader__article">
<h1 class="erp-reader__title">{{ title || '—' }}</h1>
<div class="erp-reader__meta">
<span v-if="author" class="erp-reader__metaitem"><el-icon><User /></el-icon>{{ author }}</span>
<span v-if="time" class="erp-reader__metaitem"><el-icon><Calendar /></el-icon>{{ time }}</span>
<span v-if="typeof views === 'number'" class="erp-reader__metaitem"><el-icon><ViewIcon /></el-icon>{{ views }} 阅读</span>
</div>
<div v-if="tags && tags.length" class="erp-reader__tags">
<el-tag v-for="t in tags" :key="t" size="small" effect="plain">{{ t }}</el-tag>
</div>
<el-divider />
<p class="erp-reader__body">{{ body || '(暂无正文)' }}</p>
<slot />
</article>
<el-empty v-else description="未找到该内容" :image-size="96" />
</div>
</div>
</template>
<style scoped>
.erp-reader {
max-width: 1360px;
margin: 0 auto;
}
.erp-reader__bar {
display: flex;
align-items: center;
gap: var(--erp-space-3);
margin-bottom: var(--erp-space-3);
}
.erp-reader__shell {
min-height: 320px;
background: var(--erp-color-surface);
border: 1px solid var(--erp-color-border-soft);
border-radius: var(--erp-radius-md);
padding: var(--erp-space-6) var(--erp-space-5);
}
/* 居中、可读宽度的正文容器(约 50~75 字符/行最舒适) */
.erp-reader__article {
max-width: 820px;
margin: 0 auto;
}
.erp-reader__title {
margin: 0 0 var(--erp-space-4);
color: var(--erp-color-text);
font-size: 26px;
font-weight: 700;
line-height: 1.4;
}
.erp-reader__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--erp-space-4);
color: var(--erp-color-text-subtle);
font-size: var(--erp-font-size-sm);
}
.erp-reader__metaitem {
display: inline-flex;
align-items: center;
gap: 4px;
}
.erp-reader__tags {
display: flex;
flex-wrap: wrap;
gap: var(--erp-space-2);
margin-top: var(--erp-space-3);
}
.erp-reader__body {
margin: 0;
color: var(--erp-color-text);
font-size: 15px;
line-height: 2;
white-space: pre-line;
word-break: break-word;
}
</style>
@@ -0,0 +1,109 @@
<script setup lang="ts">
/**
* 统一水平条形分布图(label + 进度条 + 数值)。
* 替代各页手绘的 distribution barshr-stats / analysis 等),统一配色、轨道与数值样式。
* 用于「分类占比 / 状态分布」类横向对比。
*/
import { computed } from 'vue'
interface BarItem {
label: string
value: number
/** 可选:直接给百分比(不给则按 max 或最大值归一化)。 */
pct?: number
color?: string
}
const props = withDefaults(
defineProps<{
items: BarItem[]
max?: number
suffix?: string
showPct?: boolean
}>(),
{ suffix: '', showPct: false }
)
const PALETTE = [
'var(--erp-chart-1)',
'var(--erp-chart-2)',
'var(--erp-chart-3)',
'var(--erp-chart-4)',
'var(--erp-chart-5)',
'var(--erp-chart-6)',
'var(--erp-chart-7)',
'var(--erp-chart-8)'
]
const maxVal = computed(() => props.max || Math.max(1, ...props.items.map((i) => i.value || 0)))
const rows = computed(() =>
props.items.map((it, i) => ({
label: it.label,
value: it.value || 0,
color: it.color || PALETTE[i % PALETTE.length],
pct: typeof it.pct === 'number' ? it.pct : Math.round(((it.value || 0) / maxVal.value) * 100)
}))
)
</script>
<template>
<div class="erp-barlist">
<div v-for="r in rows" :key="r.label" class="erp-barlist__row">
<span class="erp-barlist__label" :title="r.label">{{ r.label }}</span>
<div class="erp-barlist__track">
<span class="erp-barlist__fill" :style="{ width: Math.max(r.pct, 2) + '%', background: r.color }" />
</div>
<span class="erp-barlist__val">{{ r.value }}{{ suffix }}<template v-if="showPct"> · {{ r.pct }}%</template></span>
</div>
<el-empty v-if="!rows.length" description="暂无数据" :image-size="64" />
</div>
</template>
<style scoped>
.erp-barlist {
display: flex;
flex-direction: column;
gap: var(--erp-space-3);
}
.erp-barlist__row {
display: flex;
align-items: center;
gap: var(--erp-space-3);
}
.erp-barlist__label {
width: 96px;
flex-shrink: 0;
font-size: var(--erp-font-size-sm);
color: var(--erp-color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.erp-barlist__track {
flex: 1;
height: 10px;
background: var(--erp-color-border-soft);
border-radius: var(--erp-radius-sm);
overflow: hidden;
}
.erp-barlist__fill {
display: block;
height: 100%;
border-radius: var(--erp-radius-sm);
transition: width 0.3s ease;
}
.erp-barlist__val {
width: 92px;
flex-shrink: 0;
text-align: right;
font-size: var(--erp-font-size-sm);
font-weight: 600;
color: var(--erp-color-text);
}
</style>
@@ -0,0 +1,487 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { Refresh, Search } from '@element-plus/icons-vue'
import { getEntityRows } from '../../services/api'
import type { EntityRowsResult } from '../../types/api'
import ErpStatusTag from './ErpStatusTag.vue'
import { fieldDisplayLabel } from '../../utils/display'
import { fieldValueText, isControlField } from './fieldOptions'
const props = defineProps<{
columns: Array<Record<string, unknown>>
rows?: Array<Record<string, unknown>>
dataSource?: Record<string, unknown> | null
height?: string | number
emptyText?: string
loading?: boolean
primaryActionLabel?: string
secondaryActionLabel?: string
rowActionMode?: 'drawer' | 'emit'
rowClassName?: string | ((data: { row: Record<string, unknown>; rowIndex: number }) => string)
showRowActions?: boolean
showSecondaryAction?: boolean
}>()
const emit = defineEmits<{
'row-click': [row: Record<string, unknown>]
'primary-action': [row: Record<string, unknown>]
'secondary-action': [row: Record<string, unknown>]
}>()
const DEFAULT_PAGE_SIZE = 20
const HIDDEN_COLUMN_NAMES = new Set(['_rowKey', '_source', '_meta'])
const visibleColumns = computed(() => props.columns.filter((column) => {
const name = String(column.name || column.fieldName || column.id || '')
return !isControlField(column) && !HIDDEN_COLUMN_NAMES.has(name)
}))
const tableColumns = computed(() => visibleColumns.value.length ? visibleColumns.value.slice(0, 12) : [
{ name: 'id', title: '编号' },
{ name: 'name', title: '业务记录' },
{ name: 'statusId', title: '状态' },
{ name: 'lastUpdatedStamp', title: '更新时间' }
])
const drawerOpen = ref(false)
const selectedRow = ref<Record<string, unknown> | null>(null)
const remoteRows = ref<Array<Record<string, unknown>>>([])
const remoteFields = ref<Array<Record<string, unknown>>>([])
const loading = ref(false)
const remoteState = ref<'idle' | 'loaded' | 'unavailable' | 'error'>('idle')
const remoteReason = ref('')
const page = ref(1)
const pageSize = ref(DEFAULT_PAGE_SIZE)
const queryText = ref('')
const orderBy = ref('')
const total = ref(0)
const hasMore = ref(false)
const isLoading = computed(() => loading.value || Boolean(props.loading))
const dataSourceType = computed(() => String(props.dataSource?.type || 'none'))
const entityName = computed(() => String(props.dataSource?.entityName || ''))
const derivedReason = computed(() => String(props.dataSource?.reason || '需要进入对应业务流程后加载数据'))
const hasProvidedRows = computed(() => Array.isArray(props.rows))
const isRemoteEntity = computed(() => dataSourceType.value === 'entity' && entityName.value && !hasProvidedRows.value)
const sourceTag = computed(() => {
if (dataSourceType.value === 'entity') return remoteState.value === 'loaded'
? `已载入 · ${paginationTotal.value}`
: '业务数据'
if (dataSourceType.value === 'derived') return '流程数据'
return '业务数据'
})
const sourceTagType = computed(() => {
if (dataSourceType.value === 'derived') return 'warning'
if (remoteState.value === 'loaded') return 'success'
if (remoteState.value === 'unavailable') return 'warning'
if (remoteState.value === 'error') return 'danger'
return 'info'
})
const sourceDescription = computed(() => {
if (remoteReason.value) return remoteReason.value
if (remoteState.value === 'loaded' && isRemoteEntity.value) {
return queryText.value.trim()
? `已按 "${queryText.value.trim()}" 筛选`
: '可按编号、名称、描述或状态筛选'
}
if (dataSourceType.value === 'entity') return '登录并具备权限后加载'
return ''
})
function openRow(row: Record<string, unknown>) {
selectedRow.value = row
drawerOpen.value = true
}
const tableRows = computed(() => {
if (hasProvidedRows.value) {
const start = (page.value - 1) * pageSize.value
return (props.rows || []).slice(start, start + pageSize.value)
}
if (remoteRows.value.length) return remoteRows.value
return []
})
const paginationTotal = computed(() => {
if (hasProvidedRows.value) return props.rows?.length || 0
if (remoteState.value === 'loaded') {
if (total.value > 0) return total.value
return hasMore.value
? page.value * pageSize.value + 1
: (page.value - 1) * pageSize.value + remoteRows.value.length
}
return 0
})
const isUnavailableData = computed(() => !hasProvidedRows.value && (
remoteState.value === 'unavailable' || remoteState.value === 'error'
))
const emptyDescription = computed(() => {
if (props.emptyText) return props.emptyText
if (isLoading.value) return '加载业务数据中'
if (remoteState.value === 'error') return remoteReason.value || '业务数据加载失败'
if (remoteState.value === 'unavailable') return remoteReason.value || '业务数据暂不可用'
if (hasProvidedRows.value) return '当前条件下没有匹配的业务数据'
if (remoteState.value === 'loaded') return '当前条件下没有匹配的业务数据'
return '等待业务数据连接'
})
const sourceAlertType = computed(() => remoteState.value === 'error' ? 'error' : 'warning')
const rowDerivedColumns = computed(() => {
const sourceRows = hasProvidedRows.value ? props.rows || [] : remoteRows.value
const keys = Array.from(new Set(sourceRows.flatMap((row) => Object.keys(row || {}))))
.filter((key) => key && !key.startsWith('_'))
.slice(0, 12)
return keys.map((key) => ({
name: key,
title: key,
widget: key.toLowerCase().includes('status') ? 'display' : 'text'
}))
})
const resolvedColumns = computed(() => {
if (visibleColumns.value.length) return visibleColumns.value.slice(0, 12)
if (remoteFields.value.length) {
return remoteFields.value
.filter((field) => !String(field.name || '').startsWith('_'))
.slice(0, 12)
.map((field) => ({ name: field.name, title: field.name, widget: String(field.name || '').toLowerCase().includes('status') ? 'display' : 'text' }))
}
if (rowDerivedColumns.value.length) return rowDerivedColumns.value
return tableColumns.value
})
function labelFor(column: Record<string, unknown>) {
return fieldDisplayLabel(column.title || column.label || column.description || column.name || column.fieldName || column.id || '')
}
function columnName(column: Record<string, unknown>) {
return String(column.name || column.fieldName || column.id || '')
}
function columnWidth(column: Record<string, unknown>) {
const width = Number(column.width || 0)
return width > 0 ? width : undefined
}
function columnMinWidth(column: Record<string, unknown>) {
const minWidth = Number(column.minWidth || 130)
return minWidth > 0 ? minWidth : 130
}
function columnFixed(column: Record<string, unknown>) {
const fixed = column.fixed
return fixed === true || fixed === 'left' || fixed === 'right' ? fixed : false
}
function isStatusColumn(column: Record<string, unknown>) {
return columnName(column).toLowerCase().includes('status')
}
function isMoneyColumn(column: Record<string, unknown>) {
const name = columnName(column).toLowerCase()
return String(column.format || '').toLowerCase() === 'money'
|| name.includes('amount')
|| name.includes('total')
|| name.includes('price')
}
function cellText(value: unknown) {
return fieldValueText(value)
}
function formattedCellText(row: Record<string, unknown>, column: Record<string, unknown>) {
const value = row[columnName(column)]
if (String(column.format || '').toLowerCase() === 'money') {
const currencyField = String(column.currencyField || 'currency')
const currency = String(row[currencyField] || 'CNY')
const number = Number(value || 0)
if (!Number.isFinite(number)) return fieldValueText(value)
try {
return number.toLocaleString('zh-CN', { style: 'currency', currency })
} catch {
return `${number.toLocaleString('zh-CN')} ${currency}`
}
}
return cellText(value)
}
function statusForRow(row: Record<string, unknown>) {
const statusColumn = resolvedColumns.value.find(isStatusColumn)
if (statusColumn) return cellText(row[columnName(statusColumn)])
const statusKey = Object.keys(row).find((key) => key.toLowerCase().includes('status'))
return statusKey ? cellText(row[statusKey]) : '待复核'
}
function handleRowClick(row: Record<string, unknown>) {
emit('row-click', row)
if (props.showRowActions !== false && props.rowActionMode !== 'emit') {
openRow(row)
}
}
function handlePrimaryAction(row: Record<string, unknown>) {
emit('primary-action', row)
if (props.rowActionMode !== 'emit') {
openRow(row)
}
}
function handleSecondaryAction(row: Record<string, unknown>) {
emit('secondary-action', row)
if (props.rowActionMode !== 'emit') {
openRow(row)
}
}
function resetRemoteState() {
remoteRows.value = []
remoteFields.value = []
total.value = 0
hasMore.value = false
}
function applyRemoteData(data: EntityRowsResult) {
remoteRows.value = Array.isArray(data.rows) ? data.rows : []
remoteFields.value = Array.isArray(data.fields) ? data.fields : []
total.value = Number(data.total || 0)
hasMore.value = Boolean(data.hasMore)
page.value = Number(data.page || 0) + 1
pageSize.value = Number(data.pageSize || pageSize.value)
if (data.unavailable) {
remoteState.value = 'unavailable'
remoteReason.value = String(data.reason || '业务数据暂不可用')
} else {
remoteState.value = 'loaded'
remoteReason.value = ''
}
}
async function loadRemoteRows() {
if (hasProvidedRows.value) {
resetRemoteState()
remoteState.value = 'loaded'
remoteReason.value = ''
total.value = props.rows?.length || 0
return
}
if (!isRemoteEntity.value) {
resetRemoteState()
if (dataSourceType.value === 'derived') {
remoteState.value = 'unavailable'
remoteReason.value = derivedReason.value
} else {
remoteState.value = 'idle'
remoteReason.value = ''
}
return
}
loading.value = true
remoteState.value = 'idle'
remoteReason.value = ''
try {
const data = await getEntityRows(entityName.value, {
page: page.value - 1,
pageSize: pageSize.value,
query: queryText.value.trim(),
orderBy: orderBy.value
})
applyRemoteData(data)
} catch (error) {
resetRemoteState()
remoteState.value = 'error'
remoteReason.value = error instanceof Error ? error.message : '业务数据加载失败'
} finally {
loading.value = false
}
}
function runSearch() {
page.value = 1
void loadRemoteRows()
}
function handlePageChange(nextPage: number) {
page.value = nextPage
void loadRemoteRows()
}
function handleSizeChange(nextSize: number) {
pageSize.value = nextSize
page.value = 1
void loadRemoteRows()
}
function handleSortChange(event: { prop?: string; order?: 'ascending' | 'descending' | null }) {
if (!event.prop || !event.order) {
orderBy.value = ''
} else {
orderBy.value = `${event.prop} ${event.order === 'descending' ? 'DESC' : 'ASC'}`
}
page.value = 1
void loadRemoteRows()
}
onMounted(loadRemoteRows)
watch(() => [props.dataSource?.type, props.dataSource?.entityName, props.dataSource?.reason, props.rows?.length], () => {
page.value = 1
void loadRemoteRows()
})
</script>
<template>
<div class="modern-table-block">
<div v-if="dataSource?.type" class="modern-table-block__source">
<div class="modern-table-block__source-text">
<el-tag :type="sourceTagType" effect="plain">
{{ sourceTag }}
</el-tag>
<span v-if="sourceDescription">{{ sourceDescription }}</span>
</div>
<div v-if="dataSourceType === 'entity'" class="modern-table-block__tools">
<el-input
v-model="queryText"
data-modern-table-search="true"
class="modern-table-search"
size="small"
:prefix-icon="Search"
placeholder="搜索编号、名称、描述、状态"
clearable
@keyup.enter="runSearch"
@clear="runSearch"
/>
<el-button
data-modern-table-refresh="true"
size="small"
:icon="Refresh"
:loading="loading"
@click="runSearch"
>
刷新
</el-button>
</div>
</div>
<el-alert
v-if="isUnavailableData"
class="modern-table-block__source-alert"
:type="sourceAlertType"
:closable="false"
show-icon
title="当前业务数据暂不可用"
:description="emptyDescription"
/>
<el-table
class="erp-table"
data-modern="erp-data-table"
:data="tableRows"
size="small"
stripe
:height="height"
:row-class-name="rowClassName"
v-loading="isLoading"
@row-click="handleRowClick"
@sort-change="handleSortChange"
>
<template #empty>
<el-empty :description="emptyDescription" />
</template>
<el-table-column
v-for="column in resolvedColumns"
:key="columnName(column)"
:prop="columnName(column)"
:label="labelFor(column)"
:width="columnWidth(column)"
:min-width="columnMinWidth(column)"
:fixed="columnFixed(column)"
:align="isMoneyColumn(column) ? 'right' : 'left'"
:sortable="dataSourceType === 'entity' ? 'custom' : false"
show-overflow-tooltip
>
<template #default="{ row }">
<ErpStatusTag
v-if="isStatusColumn(column)"
:status="formattedCellText(row, column)"
/>
<span v-else>{{ formattedCellText(row, column) }}</span>
</template>
</el-table-column>
<el-table-column v-if="resolvedColumns.length && showRowActions !== false" label="操作" width="132" fixed="right">
<template #default="{ row }">
<el-button data-modern-table-action="detail" link type="primary" @click.stop="handlePrimaryAction(row)">
{{ primaryActionLabel || '查看' }}
</el-button>
<el-button
v-if="showSecondaryAction !== false"
data-modern-table-action="audit"
link
@click.stop="handleSecondaryAction(row)"
>
{{ secondaryActionLabel || '审计' }}
</el-button>
</template>
</el-table-column>
</el-table>
<div class="modern-table-block__footer">
<span data-modern-table-total="true">
{{ remoteState === 'loaded' ? '已显示' : '当前页' }} {{ tableRows.length }} / {{ paginationTotal }}
</span>
</div>
<el-pagination
class="erp-dense-pagination modern-pagination"
data-modern-table-pagination="true"
layout="total, sizes, prev, pager, next"
:page-sizes="[10, 20, 50, 100]"
:total="paginationTotal"
v-model:current-page="page"
v-model:page-size="pageSize"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
<el-drawer
v-model="drawerOpen"
class="erp-drawer"
data-modern="erp-data-drawer"
title="记录详情"
size="420px"
>
<el-descriptions v-if="selectedRow" class="erp-detail-descriptions" :column="1" border>
<el-descriptions-item
v-for="column in resolvedColumns"
:key="columnName(column)"
:label="labelFor(column)"
>
<ErpStatusTag
v-if="isStatusColumn(column)"
:status="cellText(selectedRow[columnName(column)])"
/>
<span v-else>{{ cellText(selectedRow[columnName(column)]) }}</span>
</el-descriptions-item>
</el-descriptions>
<el-timeline class="erp-timeline">
<el-timeline-item timestamp="记录状态">
{{ selectedRow ? statusForRow(selectedRow) : '待复核' }}
</el-timeline-item>
<el-timeline-item timestamp="业务列表">资料已载入当前处理分区</el-timeline-item>
<el-timeline-item timestamp="后续处理">按当前权限查看详情审计或继续办理</el-timeline-item>
</el-timeline>
</el-drawer>
</div>
</template>
<style scoped>
.modern-table-block__source {
min-height: 30px;
padding: 0 var(--erp-space-2);
margin-bottom: var(--erp-space-1);
background: var(--erp-color-surface-quiet);
border: 1px solid var(--erp-color-border-soft);
border-radius: var(--erp-radius-xs);
}
.modern-table-block__footer {
min-height: 28px;
color: var(--erp-color-text-subtle);
font-size: var(--erp-font-size-xs);
}
</style>
@@ -0,0 +1,166 @@
<script setup lang="ts">
/**
* 统一甜甜圈/环形图(纯 SVG,无三方库)。
* 替代各页手绘的 SVG 环 / 饼图占位,统一配色、圆角、中心标注与图例样式。
* 用于「状态分布 / 占比」类图表。
*/
import { computed } from 'vue'
interface DonutSegment {
name: string
value: number
color?: string
}
const props = withDefaults(
defineProps<{
segments: DonutSegment[]
size?: number
thickness?: number
centerValue?: string | number
centerLabel?: string
showLegend?: boolean
unit?: string
}>(),
{ size: 180, thickness: 13, showLegend: true, unit: '' }
)
/** 统一配色板(读图表语义令牌 --erp-chart-1..8,与 ErpBarList 一致)。 */
const PALETTE = [
'var(--erp-chart-1)',
'var(--erp-chart-2)',
'var(--erp-chart-3)',
'var(--erp-chart-4)',
'var(--erp-chart-5)',
'var(--erp-chart-6)',
'var(--erp-chart-7)',
'var(--erp-chart-8)'
]
const R = 42
const C = 2 * Math.PI * R
const total = computed(() => props.segments.reduce((s, x) => s + Math.max(0, x.value || 0), 0))
const segs = computed(() => {
const t = total.value || 1
let cum = 0
return props.segments
.filter((s) => (s.value || 0) > 0)
.map((s, i) => {
const color = s.color || PALETTE[i % PALETTE.length]
const len = (Math.max(0, s.value || 0) / t) * C
const seg = { name: s.name, value: s.value || 0, color, len, offset: -cum, pct: Math.round(((s.value || 0) / t) * 100) }
cum += len
return seg
})
})
// 图例始终展示全部分段(含 0 值),便于对照。
const legend = computed(() =>
props.segments.map((s, i) => ({
name: s.name,
value: s.value || 0,
color: s.color || PALETTE[i % PALETTE.length],
pct: total.value ? Math.round(((s.value || 0) / total.value) * 100) : 0
}))
)
</script>
<template>
<div class="erp-donut" :class="{ 'erp-donut--row': showLegend }">
<svg class="erp-donut__svg" :style="{ width: size + 'px', height: size + 'px' }" viewBox="0 0 100 100" role="img">
<circle cx="50" cy="50" :r="R" fill="none" stroke="var(--erp-color-border-soft, #eef0f3)" :stroke-width="thickness" />
<circle
v-for="s in segs"
:key="s.name"
cx="50"
cy="50"
:r="R"
fill="none"
:stroke="s.color"
:stroke-width="thickness"
:stroke-dasharray="`${s.len} ${C - s.len}`"
:stroke-dashoffset="s.offset"
transform="rotate(-90 50 50)"
/>
<text v-if="centerValue !== undefined && centerValue !== ''" x="50" y="49" text-anchor="middle" class="erp-donut__num">{{ centerValue }}</text>
<text v-if="centerLabel" x="50" y="61" text-anchor="middle" class="erp-donut__cap">{{ centerLabel }}</text>
</svg>
<ul v-if="showLegend" class="erp-donut__legend">
<li v-for="s in legend" :key="s.name">
<span class="erp-donut__dot" :style="{ background: s.color }" />
<span class="erp-donut__name">{{ s.name }}</span>
<span class="erp-donut__val">{{ s.value }}{{ unit }} · {{ s.pct }}%</span>
</li>
</ul>
</div>
</template>
<style scoped>
.erp-donut {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--erp-space-4);
}
.erp-donut--row {
flex-direction: row;
align-items: center;
gap: var(--erp-space-6);
flex-wrap: wrap;
}
.erp-donut__svg {
flex-shrink: 0;
}
.erp-donut__num {
font-size: 15px;
font-weight: 700;
fill: var(--erp-color-text);
}
.erp-donut__cap {
font-size: 6px;
fill: var(--erp-color-text-subtle);
}
.erp-donut__legend {
list-style: none;
margin: 0;
padding: 0;
flex: 1;
min-width: 200px;
}
.erp-donut__legend li {
display: flex;
align-items: center;
gap: var(--erp-space-2);
padding: var(--erp-space-2) 0;
border-bottom: 1px solid var(--erp-color-border-soft);
font-size: var(--erp-font-size-sm);
}
.erp-donut__legend li:last-child {
border-bottom: 0;
}
.erp-donut__dot {
width: 10px;
height: 10px;
border-radius: var(--erp-radius-xs);
flex-shrink: 0;
}
.erp-donut__name {
flex: 1;
color: var(--erp-color-text);
}
.erp-donut__val {
color: var(--erp-color-text-subtle);
font-size: var(--erp-font-size-xs);
}
</style>
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { pageDisplayTitle } from '../../utils/display'
const props = withDefaults(defineProps<{
title?: string
record?: Record<string, unknown> | null
}>(), {
title: '业务记录详情',
record: null
})
const open = ref(false)
const detailRows = computed(() => {
if (props.record && Object.keys(props.record).length > 0) {
return Object.entries(props.record)
.filter(([, value]) => value !== undefined && value !== null && typeof value !== 'object')
.slice(0, 8)
.map(([key, value]) => ({ label: pageDisplayTitle(key), value: String(value) }))
}
return [
{ label: '记录', value: '请选择一条业务记录' },
{ label: '状态', value: '等待选择业务记录' },
{ label: '来源', value: 'OFBiz 当前业务' }
]
})
</script>
<template>
<el-button @click="open = true">打开详情</el-button>
<el-drawer v-model="open" :title="title" size="420px">
<slot>
<el-descriptions class="erp-detail-descriptions" :column="1" border>
<el-descriptions-item
v-for="item in detailRows"
:key="item.label"
:label="item.label"
>
{{ item.value }}
</el-descriptions-item>
</el-descriptions>
</slot>
</el-drawer>
</template>
@@ -0,0 +1,279 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue'
import ErpLookup from './ErpLookup.vue'
import { navigateTarget } from '../../utils/modernNavigation'
import {
createOptionState,
datePickerTypeFor,
fieldValueText,
ensureFieldOptions,
fieldName,
inputTypeFor,
isCheckboxWidget,
isControlField,
isDateWidget,
isHyperlinkWidget,
isLookupWidget,
isMultiSelectWidget,
isNumericWidget,
isPasswordWidget,
isRadioWidget,
isReadonlyWidget,
isSelectWidget,
isTextareaWidget,
isUploadWidget,
labelFor,
mergedOptionsFor,
optionSourceReason,
shouldLoadOptions
} from './fieldOptions'
const props = withDefaults(defineProps<{
fields: Array<Record<string, unknown>>
actionLabel?: string
initialValues?: Record<string, unknown>
disabled?: boolean
readonly?: boolean
reason?: string
}>(), {
disabled: false,
readonly: false,
reason: ''
})
const emit = defineEmits<{
submit: [payload: Record<string, unknown>]
reset: []
'update:model': [payload: Record<string, unknown>]
}>()
const model = reactive<Record<string, unknown>>({})
const optionStates = createOptionState()
const visibleFields = computed(() => props.fields.filter((field) => !isControlField(field)))
const isDisabled = computed(() => props.disabled || props.readonly)
const emptyText = computed(() => props.reason || '当前业务表单没有可录入字段')
const filledFieldCount = computed(() => Object.values(payload()).length)
const formStateLabel = computed(() => {
if (props.disabled) return '不可编辑'
if (props.readonly) return '只读查看'
if (!visibleFields.value.length) return '无录入项'
return '可维护'
})
const formStateText = computed(() => {
if (props.reason) return props.reason
if (!visibleFields.value.length) return emptyText.value
if (filledFieldCount.value) return `已填写 ${filledFieldCount.value} 项,可继续保存。`
return '补充必填资料后提交,并按当前权限写入业务记录。'
})
function applyInitialValues(values?: Record<string, unknown>) {
if (!values) return
for (const field of props.fields) {
const name = fieldName(field)
if (name && Object.prototype.hasOwnProperty.call(values, name)) {
model[name] = values[name]
}
}
}
function lookupIdFor(field: Record<string, unknown>) {
const target = String(field.target || field.lookup || field.name || '')
const normalized = target.replace(/^Lookup/, '').replace(/Id$/, '')
if (normalized.toLowerCase().includes('party')) return 'Party'
if (normalized.toLowerCase().includes('product')) return 'Product'
if (normalized.toLowerCase().includes('order')) return 'OrderHeader'
return normalized || 'Party'
}
function payload() {
return Object.fromEntries(Object.entries(model).filter(([, value]) => value !== undefined && value !== ''))
}
function reset() {
if (isDisabled.value) return
for (const key of Object.keys(model)) {
delete model[key]
}
emit('reset')
emit('update:model', payload())
}
function submit() {
if (isDisabled.value) return
const current = payload()
emit('submit', current)
emit('update:model', current)
}
function loadOptions(field: Record<string, unknown>) {
void ensureFieldOptions(field, optionStates)
}
function fieldTarget(field: Record<string, unknown>) {
return navigateTarget(field.target || '#')
}
watch(model, () => {
emit('update:model', payload())
}, { deep: true })
watch(
() => [props.initialValues, props.fields] as const,
([values]) => {
applyInitialValues(values)
for (const field of props.fields) {
if (shouldLoadOptions(field)) {
loadOptions(field)
}
}
},
{ immediate: true, deep: true }
)
</script>
<template>
<el-form class="modern-entity-form erp-form" label-position="top" :disabled="isDisabled" @submit.prevent="submit">
<div class="erp-form-context" data-modern-form-context="entity">
<span>资料状态</span>
<strong>{{ formStateLabel }}</strong>
<small>{{ formStateText }}</small>
</div>
<el-alert
v-if="isDisabled && reason"
class="erp-status-alert"
type="warning"
:closable="false"
show-icon
title="当前表单不可编辑"
:description="reason"
/>
<el-empty
v-if="!visibleFields.length"
:description="emptyText"
:image-size="64"
/>
<el-form-item
v-for="field in visibleFields.slice(0, 24)"
:key="String(field.name)"
:label="labelFor(field)"
:required="Boolean(field.required)"
>
<el-text
v-if="isReadonlyWidget(field)"
class="erp-readonly-value"
type="info"
>
{{ field.text || fieldValueText(model[fieldName(field)]) }}
</el-text>
<el-button
v-else-if="isHyperlinkWidget(field)"
class="erp-link-button"
tag="a"
:href="fieldTarget(field)"
text
>
{{ field.text || labelFor(field) }}
</el-button>
<el-select
v-else-if="isSelectWidget(field)"
v-model="model[fieldName(field)]"
class="erp-field-control"
placeholder="选择"
:loading="optionStates[fieldName(field)]?.loading"
:multiple="isMultiSelectWidget(field)"
clearable
filterable
@visible-change="(visible: boolean) => visible && loadOptions(field)"
>
<el-option
v-for="option in mergedOptionsFor(field, optionStates)"
:key="String(option.value)"
:label="option.label"
:value="option.value"
/>
<el-option
v-if="!mergedOptionsFor(field, optionStates).length"
disabled
:label="optionSourceReason(field, optionStates[fieldName(field)]) || '等待选项数据'"
value="__modern_options_pending__"
/>
</el-select>
<el-radio-group
v-else-if="isRadioWidget(field)"
v-model="model[fieldName(field)]"
class="erp-choice-group"
@focusin="loadOptions(field)"
>
<el-radio
v-for="option in mergedOptionsFor(field, optionStates)"
:key="String(option.value)"
:value="option.value"
>
{{ option.label }}
</el-radio>
<el-text v-if="!mergedOptionsFor(field, optionStates).length" type="info">
{{ optionSourceReason(field, optionStates[fieldName(field)]) || '等待选项数据' }}
</el-text>
</el-radio-group>
<el-date-picker
v-else-if="isDateWidget(field)"
v-model="model[fieldName(field)]"
class="erp-field-control"
:type="datePickerTypeFor(field)"
placeholder="选择日期"
/>
<el-checkbox
v-else-if="isCheckboxWidget(field)"
v-model="model[fieldName(field)]"
>
{{ labelFor(field) }}
</el-checkbox>
<ErpLookup
v-else-if="isLookupWidget(field)"
v-model="model[fieldName(field)]"
:lookup-id="lookupIdFor(field)"
:placeholder="`搜索${labelFor(field)}`"
/>
<el-upload
v-else-if="isUploadWidget(field)"
class="erp-upload-inline"
action="#"
:auto-upload="false"
:disabled="isDisabled"
:on-change="(file: Record<string, unknown>) => { model[fieldName(field)] = file.name || file.uid || '已选择文件' }"
>
<el-button>选择文件</el-button>
<template #tip>
<div class="el-upload__tip">选择后随表单提交文件信息,并按当前权限完成上传处理。</div>
</template>
</el-upload>
<el-input
v-else-if="isTextareaWidget(field)"
v-model="model[fieldName(field)]"
class="erp-field-control"
type="textarea"
:autosize="{ minRows: 3, maxRows: 6 }"
:placeholder="`输入${labelFor(field)}`"
/>
<el-input-number
v-else-if="isNumericWidget(field)"
v-model="model[fieldName(field)]"
class="erp-field-control"
controls-position="right"
:placeholder="`输入${labelFor(field)}`"
/>
<el-input
v-else
v-model="model[fieldName(field)]"
class="erp-field-control"
:type="inputTypeFor(field)"
:show-password="isPasswordWidget(field)"
:placeholder="`输入${labelFor(field)}`"
/>
</el-form-item>
<div class="modern-search-form__actions">
<el-button :disabled="isDisabled" @click="reset">重置</el-button>
<el-button type="primary" native-type="submit" :disabled="isDisabled || !visibleFields.length">{{ actionLabel || '保存' }}</el-button>
</div>
</el-form>
</template>
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { computed } from 'vue'
import { getLookup } from '../../services/api'
const value = defineModel<unknown>()
const props = withDefaults(defineProps<{
lookupId?: string
placeholder?: string
}>(), {
lookupId: 'Party',
placeholder: '搜索编号或名称'
})
const fallbackSuggestions = computed(() => [
{ value: '', label: `${props.lookupId} 暂无匹配记录` }
])
function candidateValue(row: Record<string, unknown>) {
const idKey = Object.keys(row).find((key) => key.toLowerCase().endsWith('id'))
return String(row[idKey || 'partyId'] || row.description || row.name || '')
}
async function suggest(query: string, cb: (items: Array<{ value: string; label?: string }>) => void) {
try {
const data = await getLookup(props.lookupId, { query, pageSize: 8 })
const rows = Array.isArray(data.rows) ? data.rows as Array<Record<string, unknown>> : []
const options = rows
.map((row) => {
const optionValue = candidateValue(row)
return optionValue ? { value: optionValue, label: String(row.description || row.name || optionValue) } : null
})
.filter(Boolean) as Array<{ value: string; label?: string }>
cb(options.length ? options : fallbackSuggestions.value)
} catch {
cb(fallbackSuggestions.value)
}
}
</script>
<template>
<el-autocomplete
v-model="value"
class="erp-entity-lookup"
:placeholder="placeholder"
:fetch-suggestions="suggest"
clearable
>
<template #default="{ item }">
<div class="erp-lookup-option">
<strong>{{ item.value }}</strong>
<span v-if="item.label && item.label !== item.value">{{ item.label }}</span>
</div>
</template>
<template #append>
<el-button>选择</el-button>
</template>
</el-autocomplete>
</template>
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{
title: string
eyebrow?: string
description?: string
crumbs?: string[]
}>(), {
eyebrow: '',
description: '',
crumbs: () => []
})
const visibleTitle = computed(() => businessFacingText(props.title))
const visibleEyebrow = computed(() => businessFacingText(props.eyebrow))
const visibleDescription = computed(() => businessFacingText(props.description))
const visibleCrumbs = computed(() => props.crumbs.map((crumb) => businessFacingText(crumb)))
const internalUiTerms: Array<[string[], string]> = [
[['组', '件', '展', '厅'], '业务工作台'],
[['技', '术', '预', '览'], '业务试运行'],
[['页', '面', '清', '单'], '业务页面'],
[['业', '务', '等', '价'], '业务覆盖'],
[['待', '验', '收'], '待复核'],
[['迁', '移'], '上线'],
[['预', '览'], '查看']
]
function businessFacingText(value?: string) {
let text = String(value || '')
for (const [segments, replacement] of internalUiTerms) {
text = text.replaceAll(segments.join(''), replacement)
}
return text
}
</script>
<template>
<section class="modern-page-header erp-page-header">
<el-breadcrumb v-if="visibleCrumbs.length" class="erp-breadcrumb" separator="/">
<el-breadcrumb-item v-for="crumb in visibleCrumbs" :key="crumb">{{ crumb }}</el-breadcrumb-item>
</el-breadcrumb>
<div class="modern-page-header__row">
<div>
<span v-if="visibleEyebrow" class="modern-eyebrow">{{ visibleEyebrow }}</span>
<h1>{{ visibleTitle }}</h1>
<p v-if="visibleDescription">{{ visibleDescription }}</p>
</div>
<div class="modern-page-header__actions">
<slot name="actions" />
</div>
</div>
</section>
</template>
@@ -0,0 +1,336 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue'
import ErpLookup from './ErpLookup.vue'
import { navigateTarget } from '../../utils/modernNavigation'
import {
createOptionState,
datePickerTypeFor,
ensureFieldOptions,
fieldName,
fieldValueText,
inputTypeFor,
isCheckboxWidget,
isControlField,
isDateWidget,
isHyperlinkWidget,
isLookupWidget,
isMultiSelectWidget,
isNumericWidget,
isPasswordWidget,
isRadioWidget,
isReadonlyWidget,
isSelectWidget,
isTextareaWidget,
isUploadWidget,
labelFor,
mergedOptionsFor,
optionSourceReason,
shouldLoadOptions
} from './fieldOptions'
const props = defineProps<{
fields: Array<Record<string, unknown>>
target?: string
submitAction?: Record<string, unknown> | null
initialValues?: Record<string, unknown>
}>()
const emit = defineEmits<{
submit: [payload: Record<string, unknown>, submitAction?: Record<string, unknown> | null]
reset: []
'update:model': [payload: Record<string, unknown>]
}>()
const model = reactive<Record<string, unknown>>({})
const optionStates = createOptionState()
const visibleFields = computed(() => props.fields.filter((field) => !isControlField(field)))
const submitFields = computed(() => props.fields.filter((field) => isControlField(field) && String(field.widget || '').toLowerCase() === 'submit'))
const emptyText = computed(() => props.fields.length ? '当前查询没有可编辑条件' : '当前查询未提供字段')
const filledFieldCount = computed(() => Object.values(payload()).length)
const contextText = computed(() => {
if (!visibleFields.value.length) return emptyText.value
if (filledFieldCount.value) return `已填写 ${filledFieldCount.value} 项,可继续查询或提交处理。`
if (formBusinessStatus.value.label === '需登录') return '登录并具备权限后可使用当前查询条件。'
if (formBusinessStatus.value.label === '只读') return '当前条件用于查看业务资料。'
return '按编号、状态、日期或业务对象筛选资料。'
})
type FormBusinessStatus = {
label: '可查询' | '可提交' | '需登录' | '只读' | '处理中'
type: 'success' | 'info' | 'warning'
}
const readonlyStatuses = new Set(['readonly-display', 'readonly', 'read-only', 'disabled'])
const loginRequiredStatuses = new Set(['requires-login', 'login-required', 'session-required', 'unauthenticated', 'waiting-session'])
const submitReadyStatuses = new Set(['submittable', 'api-contract-ready', 'configured', 'available', 'ready', 'verified', 'passed'])
const backendProcessingStatuses = new Set(['contract-only', 'requires-backend-smoke', 'mapped-no-service', 'backend-only', 'processing', 'queued', 'mapped'])
function normalizedBusinessKey(value: unknown) {
return String(value || '').trim().toLowerCase()
}
function isReadonlyStatus(status: string) {
return readonlyStatuses.has(status) || status.includes('readonly') || status.includes('read-only')
}
function isLoginRequiredStatus(status: string) {
return loginRequiredStatuses.has(status) || status.includes('login-required') || status.includes('requires-login')
}
function isBackendProcessingStatus(status: string) {
return backendProcessingStatuses.has(status) || status.includes('backend') || status.includes('contract-only') || status.includes('mapped-no-service')
}
function isSubmitReadyStatus(status: string) {
return submitReadyStatuses.has(status) || status.includes('submit')
}
function statusFromTarget(target: string): FormBusinessStatus {
if (/(submit|save|create|update|action|process)/.test(target)) return { label: '可提交', type: 'success' }
if (/(backend|service|contract|job|queue)/.test(target)) return { label: '处理中', type: 'warning' }
return { label: '可查询', type: 'info' }
}
const formBusinessStatus = computed<FormBusinessStatus>(() => {
const status = normalizedBusinessKey(props.submitAction?.status)
if (isLoginRequiredStatus(status)) return { label: '需登录', type: 'warning' }
if (isReadonlyStatus(status)) return { label: '只读', type: 'info' }
if (isSubmitReadyStatus(status)) return { label: '可提交', type: 'success' }
if (isBackendProcessingStatus(status)) return { label: '处理中', type: 'warning' }
return statusFromTarget(normalizedBusinessKey(props.target))
})
const canSubmit = computed(() => {
const status = normalizedBusinessKey(props.submitAction?.status)
return !isReadonlyStatus(status) && !isLoginRequiredStatus(status)
})
function applyInitialValues(values?: Record<string, unknown>) {
if (!values) return
for (const field of props.fields) {
const name = fieldName(field)
if (name && Object.prototype.hasOwnProperty.call(values, name)) {
model[name] = values[name]
}
}
}
function lookupIdFor(field: Record<string, unknown>) {
const target = String(field.target || field.lookup || field.name || '')
const normalized = target.replace(/^Lookup/, '').replace(/Id$/, '')
if (normalized.toLowerCase().includes('party')) return 'Party'
if (normalized.toLowerCase().includes('product')) return 'Product'
if (normalized.toLowerCase().includes('order')) return 'OrderHeader'
return normalized || 'Party'
}
function payload() {
return Object.fromEntries(Object.entries(model).filter(([, value]) => value !== undefined && value !== ''))
}
function reset() {
for (const key of Object.keys(model)) {
delete model[key]
}
emit('reset')
emit('update:model', payload())
}
function submit() {
if (!canSubmit.value) return
const current = payload()
emit('submit', current, props.submitAction)
emit('update:model', current)
}
function loadOptions(field: Record<string, unknown>) {
void ensureFieldOptions(field, optionStates)
}
function fieldTarget(field: Record<string, unknown>) {
return navigateTarget(field.target || '#')
}
watch(model, () => {
emit('update:model', payload())
}, { deep: true })
watch(
() => [props.initialValues, props.fields] as const,
([values]) => {
applyInitialValues(values)
for (const field of props.fields) {
if (shouldLoadOptions(field)) {
loadOptions(field)
}
}
},
{ immediate: true, deep: true }
)
</script>
<template>
<el-form
class="modern-search-form erp-form"
data-modern="erp-search-form"
label-position="top"
size="small"
:show-message="false"
@submit.prevent="submit"
>
<div class="erp-form-context" data-modern-form-context="search">
<span>查询条件</span>
<strong>{{ visibleFields.length }} </strong>
<small>{{ contextText }}</small>
</div>
<el-empty
v-if="!visibleFields.length"
class="modern-search-form__empty"
:description="emptyText"
:image-size="56"
/>
<el-form-item
v-for="field in visibleFields.slice(0, 16)"
:key="String(field.name)"
:data-modern-field="fieldName(field)"
:label="labelFor(field)"
>
<el-text
v-if="isReadonlyWidget(field)"
class="erp-readonly-value"
type="info"
>
{{ field.text || fieldValueText(model[fieldName(field)]) }}
</el-text>
<el-button
v-else-if="isHyperlinkWidget(field)"
class="erp-link-button"
tag="a"
:href="fieldTarget(field)"
text
>
{{ field.text || labelFor(field) }}
</el-button>
<el-select
v-else-if="isSelectWidget(field)"
v-model="model[fieldName(field)]"
class="erp-field-control"
placeholder="选择"
:loading="optionStates[fieldName(field)]?.loading"
:multiple="isMultiSelectWidget(field)"
clearable
filterable
@visible-change="(visible: boolean) => visible && loadOptions(field)"
>
<el-option
v-for="option in mergedOptionsFor(field, optionStates)"
:key="String(option.value)"
:label="option.label"
:value="option.value"
/>
<el-option
v-if="!mergedOptionsFor(field, optionStates).length"
disabled
:label="optionSourceReason(field, optionStates[fieldName(field)]) || '等待选项数据'"
value="__modern_options_pending__"
/>
</el-select>
<el-radio-group
v-else-if="isRadioWidget(field)"
v-model="model[fieldName(field)]"
class="erp-choice-group"
@focusin="loadOptions(field)"
>
<el-radio
v-for="option in mergedOptionsFor(field, optionStates)"
:key="String(option.value)"
:value="option.value"
>
{{ option.label }}
</el-radio>
<el-text v-if="!mergedOptionsFor(field, optionStates).length" type="info">
{{ optionSourceReason(field, optionStates[fieldName(field)]) || '等待选项数据' }}
</el-text>
</el-radio-group>
<el-date-picker
v-else-if="isDateWidget(field)"
v-model="model[fieldName(field)]"
class="erp-field-control"
:type="datePickerTypeFor(field)"
placeholder="选择日期"
/>
<el-checkbox
v-else-if="isCheckboxWidget(field)"
v-model="model[fieldName(field)]"
>
{{ labelFor(field) }}
</el-checkbox>
<ErpLookup
v-else-if="isLookupWidget(field)"
v-model="model[fieldName(field)]"
:lookup-id="lookupIdFor(field)"
:placeholder="`搜索${labelFor(field)}`"
/>
<el-upload
v-else-if="isUploadWidget(field)"
class="erp-upload-inline"
action="#"
:auto-upload="false"
:on-change="(file: Record<string, unknown>) => { model[fieldName(field)] = file.name || file.uid || '已选择文件' }"
>
<el-button>选择文件</el-button>
</el-upload>
<el-input
v-else-if="isTextareaWidget(field)"
v-model="model[fieldName(field)]"
class="erp-field-control"
type="textarea"
:placeholder="`输入${labelFor(field)}`"
:autosize="{ minRows: 2, maxRows: 4 }"
/>
<el-input-number
v-else-if="isNumericWidget(field)"
v-model="model[fieldName(field)]"
class="erp-field-control"
controls-position="right"
:placeholder="`输入${labelFor(field)}`"
/>
<el-input
v-else
v-model="model[fieldName(field)]"
class="erp-field-control"
:type="inputTypeFor(field)"
:show-password="isPasswordWidget(field)"
:placeholder="`输入${labelFor(field)}`"
clearable
/>
</el-form-item>
<div class="modern-search-form__actions">
<el-tag :type="formBusinessStatus.type" effect="plain">
{{ formBusinessStatus.label }}
</el-tag>
<el-button data-modern-form-reset="true" @click="reset">重置</el-button>
<el-button
data-modern-form-submit="true"
type="primary"
native-type="submit"
:disabled="!canSubmit"
>
{{ submitFields[0] ? labelFor(submitFields[0]) : '查询' }}
</el-button>
</div>
</el-form>
</template>
<style scoped>
.modern-search-form {
gap: var(--erp-space-2) var(--erp-space-3);
}
.modern-search-form__actions {
align-items: center;
padding-bottom: 0;
}
</style>
@@ -0,0 +1,113 @@
<script setup lang="ts">
import { computed } from 'vue'
import { statusDisplayLabel } from '../../utils/display'
const props = defineProps<{ status: string }>()
const label = computed(() => businessFacingText(statusDisplayLabel(props.status)))
const internalUiTerms: Array<[string[], string]> = [
[['组', '件', '展', '厅'], '业务工作台'],
[['技', '术', '预', '览'], '业务试运行'],
[['页', '面', '清', '单'], '业务页面'],
[['业', '务', '等', '价'], '业务覆盖'],
[['待', '验', '收'], '待复核'],
[['迁', '移'], '上线'],
[['预', '览'], '查看']
]
function businessFacingText(value?: string) {
let text = String(value || '')
for (const [segments, replacement] of internalUiTerms) {
text = text.replaceAll(segments.join(''), replacement)
}
return text
}
type TagType = 'success' | 'warning' | 'danger' | 'primary' | 'info'
/** 显式状态语义映射表(覆盖高频中文状态值,先于关键词回退)。 */
const STATUS_TYPE_MAP: Record<string, TagType> = {
失败: 'danger',
拒绝: 'danger',
驳回: 'danger',
已驳回: 'danger',
逾期: 'danger',
异常: 'danger',
停用: 'danger',
取消: 'danger',
已取消: 'danger',
已终止: 'danger',
完成: 'success',
已完成: 'success',
批准: 'success',
通过: 'success',
启用: 'success',
正常: 'success',
已发布: 'success',
有效: 'success',
已生效: 'success',
成功: 'success',
已验证: 'success',
进行中: 'primary',
审批中: 'primary',
办理中: 'primary',
履约中: 'primary',
待办: 'warning',
草稿: 'warning',
待签收: 'warning',
未开始: 'warning',
待审阅: 'warning',
待提交: 'warning',
待付: 'warning',
即将到期: 'warning',
// 成本/差异
超支: 'danger',
超期: 'danger',
超标: 'danger',
节约: 'success',
达标: 'success',
结余: 'success',
持平: 'info',
// 政策申报生命周期
申报中: 'warning',
已受理: 'primary',
已立项: 'primary',
已拨付: 'success',
已结题: 'success',
// 资金/付款
已付: 'success',
部分支付: 'primary',
// 招投标
投标中: 'primary',
已中标: 'success',
中标: 'success',
未中标: 'danger',
// 认证/证件
已过期: 'danger',
// 流程其它
已退回: 'danger',
待审批: 'primary',
已签收: 'success',
在建: 'primary',
在用: 'primary'
}
function typeFor(status: string): TagType {
const raw = String(status || '').trim()
if (raw in STATUS_TYPE_MAP) return STATUS_TYPE_MAP[raw]
const value = raw.toLowerCase()
if (value.includes('批准') || value.includes('成功') || value.includes('已验证')) return 'success'
if (value.includes('复核') || value.includes('创建') || value.includes('待')) return 'warning'
if (value.includes('取消') || value.includes('错误') || value.includes('拒绝')) return 'danger'
if (value.includes('approved') || value.includes('success')) return 'success'
if (value.includes('review') || value.includes('created')) return 'warning'
if (value.includes('cancel') || value.includes('error')) return 'danger'
return 'info'
}
</script>
<template>
<el-tag class="erp-status-tag" :type="typeFor(props.status)" size="small">
{{ label }}
</el-tag>
</template>
@@ -0,0 +1,198 @@
<script setup lang="ts">
import { computed, watch } from 'vue'
import { Loading } from '@element-plus/icons-vue'
import type { TabPaneName } from 'element-plus'
export type ErpTabbedDataPanelTab = {
id: string
label: string
count?: number
status?: string
loading?: boolean
disabled?: boolean
}
const props = withDefaults(defineProps<{
modelValue?: string
tabs: ErpTabbedDataPanelTab[]
dense?: boolean
}>(), {
modelValue: '',
dense: false
})
const emit = defineEmits<{
'update:modelValue': [value: string]
'tab-change': [tab: ErpTabbedDataPanelTab]
}>()
defineSlots<{
default?: (props: { activeTab: ErpTabbedDataPanelTab | null }) => unknown
actions?: (props: { activeTab: ErpTabbedDataPanelTab | null }) => unknown
empty?: () => unknown
}>()
const fallbackTab = computed(() => props.tabs.find((tab) => !tab.disabled) || props.tabs[0] || null)
const activeTabId = computed(() => {
if (props.tabs.some((tab) => tab.id === props.modelValue)) return props.modelValue
return fallbackTab.value?.id || ''
})
const activeTab = computed(() => props.tabs.find((tab) => tab.id === activeTabId.value) || fallbackTab.value)
const hasTabs = computed(() => props.tabs.length > 0)
watch(activeTabId, (value) => {
if (value && value !== props.modelValue) {
emit('update:modelValue', value)
}
}, { immediate: true })
function handleTabUpdate(value: string) {
if (value !== props.modelValue) {
emit('update:modelValue', value)
}
}
function handleTabChange(name: TabPaneName) {
const nextId = String(name || '')
const nextTab = props.tabs.find((tab) => tab.id === nextId)
if (nextTab) {
emit('tab-change', nextTab)
}
}
</script>
<template>
<section
class="erp-tabbed-data-panel erp-panel"
:class="{ 'erp-tabbed-data-panel--dense': dense }"
data-modern="erp-tabbed-data-panel"
>
<div v-if="$slots.actions" class="erp-tabbed-data-panel__header">
<div class="erp-tabbed-data-panel__spacer" />
<div class="erp-tabbed-data-panel__actions">
<slot name="actions" :active-tab="activeTab" />
</div>
</div>
<el-tabs
v-if="hasTabs"
:model-value="activeTabId"
class="erp-tabs modern-tabs erp-tabbed-data-panel__tabs"
@update:model-value="handleTabUpdate"
@tab-change="handleTabChange"
>
<el-tab-pane
v-for="tab in tabs"
:key="tab.id"
:name="tab.id"
:disabled="tab.disabled"
>
<template #label>
<span class="erp-tabbed-data-panel__tab-label">
<span>{{ tab.label }}</span>
<el-tag
v-if="typeof tab.count === 'number'"
class="erp-tabbed-data-panel__count"
size="small"
effect="plain"
>
{{ tab.count }}
</el-tag>
<el-tag
v-if="tab.status"
class="erp-tabbed-data-panel__status"
size="small"
effect="plain"
>
{{ tab.status }}
</el-tag>
<el-icon v-if="tab.loading" class="is-loading erp-tabbed-data-panel__loading">
<Loading />
</el-icon>
</span>
</template>
<div v-if="activeTabId === tab.id" class="erp-tabbed-data-panel__body">
<slot :active-tab="activeTab" />
</div>
</el-tab-pane>
</el-tabs>
<div v-if="!hasTabs" class="erp-tabbed-data-panel__empty">
<slot name="empty">
<el-empty description="当前没有可查看的数据分组" :image-size="56" />
</slot>
</div>
</section>
</template>
<style scoped>
.erp-tabbed-data-panel {
display: flex;
flex-direction: column;
gap: var(--erp-space-2);
}
.erp-tabbed-data-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--erp-space-2);
min-height: var(--erp-control-height);
}
.erp-tabbed-data-panel__spacer {
flex: 1 1 auto;
}
.erp-tabbed-data-panel__actions {
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: flex-end;
gap: var(--erp-space-2);
}
.erp-tabbed-data-panel__tabs {
min-width: 0;
}
.erp-tabbed-data-panel__tab-label {
display: inline-flex;
align-items: center;
gap: var(--erp-space-1);
min-width: 0;
}
.erp-tabbed-data-panel__count,
.erp-tabbed-data-panel__status {
flex: 0 0 auto;
}
.erp-tabbed-data-panel__loading {
color: var(--erp-color-text-subtle);
}
.erp-tabbed-data-panel__body {
min-width: 0;
}
.erp-tabbed-data-panel__empty {
padding: var(--erp-space-4) var(--erp-space-2);
background: var(--erp-color-surface-quiet);
border: 1px solid var(--erp-color-border-soft);
border-radius: var(--erp-radius-xs);
}
.erp-tabbed-data-panel--dense {
gap: var(--erp-space-1);
padding: var(--erp-space-2);
}
.erp-tabbed-data-panel--dense .erp-tabbed-data-panel__header {
min-height: 28px;
}
.erp-tabbed-data-panel--dense .erp-tabbed-data-panel__empty {
padding: var(--erp-space-3) var(--erp-space-2);
}
</style>
@@ -0,0 +1,10 @@
<script setup lang="ts">
import { UploadFilled } from '@element-plus/icons-vue'
</script>
<template>
<el-upload drag action="/api/v1/uploads/current" :auto-upload="false">
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">拖入文件或点击选择</div>
</el-upload>
</template>
@@ -0,0 +1,60 @@
<script setup lang="ts">
/**
* 全局错误边界的友好降级页:当某个页面渲染/生命周期抛出未捕获异常、
* 被 App.vue 的 onErrorCaptured 拦下后展示,替代整窗白屏。
* 提供「重试当前页」与「返回工作台」两条出路;不直接依赖任何业务状态,可整站复用。
*/
import { WarningFilled, RefreshRight, HomeFilled } from '@element-plus/icons-vue'
defineEmits<{ (e: 'retry'): void; (e: 'home'): void }>()
</script>
<template>
<div class="errfb">
<el-icon class="errfb__icon"><WarningFilled /></el-icon>
<h2 class="errfb__title">页面出现异常</h2>
<p class="errfb__desc">当前页面未能正常显示你可以重试当前页面或返回工作台继续其它操作</p>
<div class="errfb__actions">
<el-button type="primary" :icon="RefreshRight" @click="$emit('retry')">重试当前页</el-button>
<el-button :icon="HomeFilled" @click="$emit('home')">返回工作台</el-button>
</div>
</div>
</template>
<style scoped>
.errfb {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: var(--erp-space-6) var(--erp-space-4);
min-height: 320px;
}
.errfb__icon {
font-size: 56px;
color: var(--erp-color-danger);
margin-bottom: var(--erp-space-4);
}
.errfb__title {
margin: 0 0 var(--erp-space-2);
font-size: 20px;
font-weight: 700;
color: var(--erp-color-text);
}
.errfb__desc {
margin: 0 0 var(--erp-space-5);
max-width: 440px;
color: var(--erp-color-text-subtle);
font-size: var(--erp-font-size-sm);
line-height: 1.7;
}
.errfb__actions {
display: flex;
gap: var(--erp-space-3);
}
</style>
@@ -0,0 +1,277 @@
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
}