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:
@@ -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) {
|
||||
// copy:pdfjs 可能 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.read,array 类型自动识别。
|
||||
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 bars(hr-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
|
||||
}
|
||||
Reference in New Issue
Block a user