Initial commit
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
<template>
|
||||
<button
|
||||
class="action-button"
|
||||
:class="[
|
||||
`action-button-${normalizedVariant}`,
|
||||
`action-button-${normalizedSize}`,
|
||||
{ 'action-button-icon-only': iconOnly }
|
||||
]"
|
||||
:type="type"
|
||||
:disabled="disabled || loading"
|
||||
:data-button-kind="normalizedVariant"
|
||||
:data-button-tone="buttonTone"
|
||||
>
|
||||
<AppIcon v-if="icon" :name="icon" :tone="iconTone" />
|
||||
<span v-if="!iconOnly" class="action-button-label"><slot /></span>
|
||||
<span v-if="loading" class="action-button-spinner" aria-hidden="true"></span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
import AppIcon from "./AppIcon.vue";
|
||||
|
||||
const props = defineProps({
|
||||
icon: { type: String, default: "" },
|
||||
variant: { type: String, default: "secondary" },
|
||||
size: { type: String, default: "md" },
|
||||
type: { type: String, default: "button" },
|
||||
disabled: { type: Boolean, default: false },
|
||||
loading: { type: Boolean, default: false },
|
||||
iconOnly: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
const normalizedVariant = computed(() => {
|
||||
return ["primary", "secondary", "success", "warning", "danger", "ghost"].includes(props.variant)
|
||||
? props.variant
|
||||
: "secondary";
|
||||
});
|
||||
|
||||
const normalizedSize = computed(() => (["sm", "md", "lg"].includes(props.size) ? props.size : "md"));
|
||||
|
||||
const buttonTone = computed(() => {
|
||||
if (normalizedVariant.value === "danger") return "danger";
|
||||
if (normalizedVariant.value === "primary") return "primary";
|
||||
if (normalizedVariant.value === "success") return "success";
|
||||
if (normalizedVariant.value === "warning") return "warning";
|
||||
return "neutral";
|
||||
});
|
||||
|
||||
const iconTone = computed(() => {
|
||||
if (normalizedVariant.value === "danger") return "danger";
|
||||
if (normalizedVariant.value === "primary") return "primary";
|
||||
if (normalizedVariant.value === "success") return "success";
|
||||
if (normalizedVariant.value === "warning") return "warning";
|
||||
return "muted";
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="eyebrow">异常中心</p>
|
||||
<h3>需要管理员快速处理的事项</h3>
|
||||
</div>
|
||||
<span class="panel-tag">实时优先级</span>
|
||||
</div>
|
||||
|
||||
<div class="alert-list">
|
||||
<article
|
||||
v-for="alert in alerts"
|
||||
:key="`${alert.level}-${alert.title}`"
|
||||
class="alert-item"
|
||||
:class="`alert-${alert.level}`"
|
||||
:title="alert.description"
|
||||
>
|
||||
<div class="alert-top">
|
||||
<strong>{{ alert.title }}</strong>
|
||||
<span>{{ alert.owner }}</span>
|
||||
</div>
|
||||
<div class="alert-footer">
|
||||
<span>建议动作</span>
|
||||
<strong>{{ alert.action }}</strong>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
alerts: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<svg class="app-icon" :class="[`app-icon-${tone}`]" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
v-for="path in paths"
|
||||
:key="path"
|
||||
:d="path"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
:stroke-width="strokeWidth"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { ICON_PATHS } from "../utils/iconMap";
|
||||
|
||||
const props = defineProps({
|
||||
name: { type: String, required: true },
|
||||
tone: { type: String, default: "default" },
|
||||
strokeWidth: { type: [Number, String], default: 1.8 }
|
||||
});
|
||||
|
||||
const paths = computed(() => ICON_PATHS[props.name] || ICON_PATHS.dashboard);
|
||||
</script>
|
||||
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div class="drawer-data-table-wrap">
|
||||
<table class="data-table compact-table drawer-data-table">
|
||||
<colgroup>
|
||||
<col v-for="column in visibleColumns" :key="column.key" :style="{ width: column.width || 'auto' }" />
|
||||
<col v-if="expandable" style="width: 72px" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="column in visibleColumns" :key="column.key" :title="headerTitle(column)">
|
||||
<span class="drawer-table-head-label">{{ headerLabel(column) }}</span>
|
||||
</th>
|
||||
<th v-if="expandable" title="详情">详情</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="row in rows" :key="getRowKey(row)">
|
||||
<tr>
|
||||
<td v-for="column in visibleColumns" :key="column.key" :class="cellClass(column)">
|
||||
<slot :name="`cell-${column.key}`" :row="row" :column="column" :value="row[column.key]">
|
||||
<span :class="fallbackClass(column)" :title="formatFallback(row, column)">
|
||||
{{ formatFallback(row, column) }}
|
||||
</span>
|
||||
</slot>
|
||||
</td>
|
||||
<td v-if="expandable" class="drawer-cell-actions">
|
||||
<button class="mini-icon-button" type="button" @click="toggleRow(row)">
|
||||
{{ isExpanded(row) ? "收起" : "展开" }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="expandable && isExpanded(row)" class="drawer-row-detail">
|
||||
<td :colspan="visibleColumns.length + 1">
|
||||
<slot name="detail" :row="row">
|
||||
<div class="drawer-row-detail-grid">
|
||||
<article v-for="column in detailColumns" :key="column.key">
|
||||
<span>{{ column.label }}</span>
|
||||
<strong>{{ formatFallback(row, column) }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-if="!rows.length">
|
||||
<td :colspan="visibleColumns.length + (expandable ? 1 : 0)" class="empty-row">{{ emptyText }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
rows: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
columns: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
rowKey: {
|
||||
type: [String, Function],
|
||||
default: "id"
|
||||
},
|
||||
expandedRows: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
emptyText: {
|
||||
type: String,
|
||||
default: "暂无数据"
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:expandedRows"]);
|
||||
|
||||
const visibleColumns = computed(() => props.columns.filter((column) => !column.hidden && !column.detailOnly));
|
||||
const detailColumns = computed(() => props.columns.filter((column) => column.detailOnly));
|
||||
const expandable = computed(() => detailColumns.value.length > 0);
|
||||
|
||||
function getRowKey(row) {
|
||||
return typeof props.rowKey === "function" ? props.rowKey(row) : row?.[props.rowKey];
|
||||
}
|
||||
|
||||
function isExpanded(row) {
|
||||
return props.expandedRows.includes(getRowKey(row));
|
||||
}
|
||||
|
||||
function toggleRow(row) {
|
||||
const key = getRowKey(row);
|
||||
const nextRows = isExpanded(row) ? props.expandedRows.filter((item) => item !== key) : [...props.expandedRows, key];
|
||||
emit("update:expandedRows", nextRows);
|
||||
}
|
||||
|
||||
function cellClass(column) {
|
||||
return {
|
||||
"drawer-cell-number": column.type === "number" || column.align === "right",
|
||||
"drawer-cell-status": column.type === "status",
|
||||
"drawer-cell-long": column.type === "code" || column.type === "longText"
|
||||
};
|
||||
}
|
||||
|
||||
function fallbackClass(column) {
|
||||
return {
|
||||
"drawer-cell-code": column.type === "code",
|
||||
"drawer-cell-ellipsis": column.type === "longText" || column.type === "code"
|
||||
};
|
||||
}
|
||||
|
||||
function headerLabel(column) {
|
||||
return column.compactLabel || column.label || column.title || column.key;
|
||||
}
|
||||
|
||||
function headerTitle(column) {
|
||||
return column.title || column.label || column.compactLabel || column.key;
|
||||
}
|
||||
|
||||
function formatFallback(row, column) {
|
||||
if (typeof column.format === "function") {
|
||||
return column.format(row[column.key], row);
|
||||
}
|
||||
const value = row?.[column.key];
|
||||
return value === null || value === undefined || value === "" ? "-" : String(value);
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<span
|
||||
ref="hintRoot"
|
||||
class="field-hint"
|
||||
tabindex="0"
|
||||
:aria-describedby="popoverId"
|
||||
@mouseenter="showPopover"
|
||||
@mouseleave="hidePopover"
|
||||
@focus="showPopover"
|
||||
@blur="hidePopover"
|
||||
@keydown.escape="hidePopover"
|
||||
>
|
||||
<AppIcon name="warning" tone="warning" stroke-width="1.6" />
|
||||
<Teleport to="body">
|
||||
<span
|
||||
v-if="popoverVisible"
|
||||
:id="popoverId"
|
||||
ref="hintPopover"
|
||||
class="field-hint-popover"
|
||||
:class="{
|
||||
'field-hint-popover-open': popoverPositioned,
|
||||
'field-hint-popover-bottom': popoverPlacement === 'bottom'
|
||||
}"
|
||||
:style="popoverStyle"
|
||||
role="tooltip"
|
||||
>
|
||||
{{ text }}
|
||||
</span>
|
||||
</Teleport>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, ref, useId } from "vue";
|
||||
|
||||
import AppIcon from "./AppIcon.vue";
|
||||
|
||||
defineProps({
|
||||
text: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const popoverId = `field-hint-${useId()}`;
|
||||
const hintRoot = ref(null);
|
||||
const hintPopover = ref(null);
|
||||
const popoverVisible = ref(false);
|
||||
const popoverPositioned = ref(false);
|
||||
const popoverPlacement = ref("top");
|
||||
const popoverStyle = ref({});
|
||||
|
||||
async function showPopover() {
|
||||
popoverVisible.value = true;
|
||||
popoverPositioned.value = false;
|
||||
await nextTick();
|
||||
updatePopoverPosition();
|
||||
popoverPositioned.value = true;
|
||||
}
|
||||
|
||||
function hidePopover() {
|
||||
popoverVisible.value = false;
|
||||
popoverPositioned.value = false;
|
||||
}
|
||||
|
||||
function updatePopoverPosition() {
|
||||
if (!hintRoot.value || !hintPopover.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewportPadding = 12;
|
||||
const gap = 10;
|
||||
const rootRect = hintRoot.value.getBoundingClientRect();
|
||||
const popoverRect = hintPopover.value.getBoundingClientRect();
|
||||
const maxWidth = Math.max(160, Math.min(280, window.innerWidth - viewportPadding * 2));
|
||||
const width = Math.min(popoverRect.width || maxWidth, maxWidth);
|
||||
const height = popoverRect.height || 40;
|
||||
const rootCenter = rootRect.left + rootRect.width / 2;
|
||||
const left = Math.min(Math.max(rootCenter - width / 2, viewportPadding), window.innerWidth - width - viewportPadding);
|
||||
let top = rootRect.top - height - gap;
|
||||
let placement = "top";
|
||||
|
||||
if (top < viewportPadding) {
|
||||
top = rootRect.bottom + gap;
|
||||
placement = "bottom";
|
||||
}
|
||||
|
||||
popoverPlacement.value = placement;
|
||||
popoverStyle.value = {
|
||||
left: `${Math.round(left)}px`,
|
||||
top: `${Math.round(top)}px`,
|
||||
maxWidth: `${Math.round(maxWidth)}px`,
|
||||
"--field-hint-arrow-left": `${Math.round(rootCenter - left)}px`
|
||||
};
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="eyebrow">流程闭环</p>
|
||||
<h3>订单、材料、工单、完工的联动看板</h3>
|
||||
</div>
|
||||
<span class="panel-tag">流程化管理</span>
|
||||
</div>
|
||||
|
||||
<div class="flow-grid">
|
||||
<section v-for="group in groups" :key="group.title" class="flow-group">
|
||||
<div class="flow-group-head">
|
||||
<h4>{{ group.title }}</h4>
|
||||
</div>
|
||||
<article
|
||||
v-for="step in group.items"
|
||||
:key="step.step_code"
|
||||
class="flow-step"
|
||||
:class="`flow-${step.status}`"
|
||||
:title="step.summary"
|
||||
>
|
||||
<div class="flow-step-top">
|
||||
<strong>{{ step.title }}</strong>
|
||||
<span>{{ step.quantity }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
groups: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="workflow-drawer-mask" @click.self="emit('close')">
|
||||
<aside class="workflow-drawer form-drawer" :class="drawerClass" :aria-describedby="description ? descriptionId : undefined">
|
||||
<header class="workflow-drawer-head">
|
||||
<div>
|
||||
<p class="eyebrow">{{ eyebrow }}</p>
|
||||
<div class="drawer-title-row" :title="description || undefined">
|
||||
<h3>{{ title }}</h3>
|
||||
</div>
|
||||
<p v-if="description" :id="descriptionId" class="form-drawer-description">{{ description }}</p>
|
||||
</div>
|
||||
<div class="workflow-drawer-head-actions">
|
||||
<span v-if="tag" class="panel-tag">{{ tag }}</span>
|
||||
<button class="ghost-button icon-button" type="button" aria-label="关闭" title="关闭" @click="emit('close')">
|
||||
<AppIcon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="workflow-section-card form-drawer-body">
|
||||
<slot />
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, toRef, useId } from "vue";
|
||||
|
||||
import { useBodyScrollLock } from "../composables/useBodyScrollLock";
|
||||
import AppIcon from "./AppIcon.vue";
|
||||
|
||||
const props = defineProps({
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
eyebrow: {
|
||||
type: String,
|
||||
default: "资料维护"
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
tag: {
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
wide: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: "normal"
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
const descriptionId = `form-drawer-description-${useId()}`;
|
||||
|
||||
const drawerClass = computed(() => {
|
||||
const normalizedSize = ["normal", "wide", "xl", "fullscreen"].includes(props.size) ? props.size : "normal";
|
||||
return {
|
||||
"form-drawer-wide": props.wide || normalizedSize === "wide",
|
||||
"form-drawer-xl": normalizedSize === "xl",
|
||||
"form-drawer-fullscreen": normalizedSize === "fullscreen"
|
||||
};
|
||||
});
|
||||
|
||||
useBodyScrollLock(toRef(props, "open"));
|
||||
</script>
|
||||
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<section class="form-section" :class="sectionClass">
|
||||
<header v-if="hasHeader" class="form-section-header">
|
||||
<div class="form-section-heading">
|
||||
<p v-if="eyebrow" class="form-section-eyebrow">{{ eyebrow }}</p>
|
||||
<h3 v-if="title" class="form-section-title">{{ title }}</h3>
|
||||
<p v-if="description" class="form-section-description">{{ description }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.actions" class="form-section-actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="form-section-grid">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, useSlots } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
eyebrow: {
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
columns: {
|
||||
type: [Number, String],
|
||||
default: 2,
|
||||
validator: (value) => [1, 2, 3, "1", "2", "3"].includes(value)
|
||||
}
|
||||
});
|
||||
|
||||
const slots = useSlots();
|
||||
|
||||
const normalizedColumns = computed(() => Number(props.columns) || 2);
|
||||
const hasHeader = computed(() => Boolean(props.eyebrow || props.title || props.description || slots.actions));
|
||||
|
||||
const sectionClass = computed(() => ({
|
||||
[`form-section-columns-${normalizedColumns.value}`]: true
|
||||
}));
|
||||
</script>
|
||||
@@ -0,0 +1,22 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const componentSource = readFileSync(join(__dirname, "GlobalSearchableSelect.vue"), "utf8");
|
||||
const mainCss = readFileSync(resolve(__dirname, "../styles/main.css"), "utf8");
|
||||
|
||||
describe("GlobalSearchableSelect option panel", () => {
|
||||
it("keeps disabled empty placeholder options out of the dropdown list", () => {
|
||||
assert.match(componentSource, /function isPanelPlaceholderOption\(option\)/);
|
||||
assert.match(componentSource, /\.filter\(\(\{ option \}\) => !isPanelPlaceholderOption\(option\)\)/);
|
||||
});
|
||||
|
||||
it("lays option text out as a compact two-column business row", () => {
|
||||
assert.match(mainCss, /\.global-select-layer \.global-select-option \{[\s\S]*grid-template-columns:\s*minmax\(0,\s*1fr\) auto;/);
|
||||
assert.match(mainCss, /\.global-select-layer \.global-select-option-meta \{[\s\S]*grid-column:\s*2;/);
|
||||
assert.match(mainCss, /\.global-select-layer \.global-select-option-sub \{[\s\S]*grid-column:\s*1 \/ -1;/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,440 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
ref="panelRef"
|
||||
class="global-select-layer"
|
||||
@pointerdown.stop
|
||||
@mousedown.stop
|
||||
@click.stop
|
||||
>
|
||||
<div class="global-select-input-box" :style="inputStyle">
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
v-model.trim="searchText"
|
||||
class="global-select-search-input"
|
||||
type="text"
|
||||
:placeholder="selectedLabel || '请输入关键字搜索'"
|
||||
@keydown.down.prevent="moveActive(1)"
|
||||
@keydown.up.prevent="moveActive(-1)"
|
||||
@keydown.enter.prevent="chooseActive"
|
||||
@keydown.esc.prevent="closePanel"
|
||||
/>
|
||||
<span class="global-select-arrow" aria-hidden="true"></span>
|
||||
</div>
|
||||
|
||||
<div class="global-select-panel" :style="panelStyle">
|
||||
<div class="global-select-option-list">
|
||||
<button
|
||||
v-for="(option, index) in filteredOptions"
|
||||
:key="`${option.index}-${option.value}`"
|
||||
class="global-select-option"
|
||||
:class="{
|
||||
'global-select-option-active': index === activeIndex,
|
||||
'global-select-option-selected': option.selected,
|
||||
'global-select-option-disabled': option.disabled
|
||||
}"
|
||||
type="button"
|
||||
:disabled="option.disabled"
|
||||
@mouseenter="activeIndex = index"
|
||||
@click="selectOption(option)"
|
||||
>
|
||||
<span class="global-select-option-main">{{ option.display.title }}</span>
|
||||
<span v-if="option.display.subtitle" class="global-select-option-sub">{{ option.display.subtitle }}</span>
|
||||
<span v-if="option.display.meta" class="global-select-option-meta">{{ option.display.meta }}</span>
|
||||
</button>
|
||||
<div v-if="!filteredOptions.length" class="global-select-empty">没有匹配选项</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
|
||||
const open = ref(false);
|
||||
const activeSelect = ref(null);
|
||||
const options = ref([]);
|
||||
const searchText = ref("");
|
||||
const activeIndex = ref(0);
|
||||
const panelRef = ref(null);
|
||||
const searchInputRef = ref(null);
|
||||
const panelPosition = ref({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 280,
|
||||
height: 48,
|
||||
maxHeight: 360
|
||||
});
|
||||
|
||||
const selectedLabel = computed(() => {
|
||||
const selectedOptionLabel = cleanOptionLabel(activeSelect.value?.selectedOptions?.[0]?.textContent);
|
||||
if (selectedOptionLabel) {
|
||||
return selectedOptionLabel;
|
||||
}
|
||||
const selected = options.value.find((option) => option.selected);
|
||||
return selected?.label || selected?.value || "";
|
||||
});
|
||||
|
||||
const inputStyle = computed(() => ({
|
||||
left: `${panelPosition.value.left}px`,
|
||||
top: `${panelPosition.value.top}px`,
|
||||
width: `${panelPosition.value.width}px`,
|
||||
height: `${panelPosition.value.height}px`
|
||||
}));
|
||||
|
||||
const panelStyle = computed(() => ({
|
||||
left: `${panelPosition.value.left}px`,
|
||||
top: `${panelPosition.value.top + panelPosition.value.height + 6}px`,
|
||||
width: `${panelPosition.value.width}px`,
|
||||
maxHeight: `${panelPosition.value.maxHeight}px`
|
||||
}));
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
const keywords = normalizeText(searchText.value).split(/\s+/).filter(Boolean);
|
||||
if (!keywords.length) {
|
||||
return options.value;
|
||||
}
|
||||
return options.value.filter((option) => {
|
||||
const searchBody = normalizeText(`${option.label} ${option.value}`);
|
||||
return keywords.every((keyword) => searchBody.includes(keyword));
|
||||
});
|
||||
});
|
||||
|
||||
watch(filteredOptions, (rows) => {
|
||||
if (!rows.length) {
|
||||
activeIndex.value = 0;
|
||||
return;
|
||||
}
|
||||
if (activeIndex.value >= rows.length) {
|
||||
activeIndex.value = rows.length - 1;
|
||||
}
|
||||
if (activeIndex.value < 0) {
|
||||
activeIndex.value = 0;
|
||||
}
|
||||
});
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function cleanOptionLabel(value) {
|
||||
return String(value || "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function splitOptionLabel(label) {
|
||||
const parts = cleanOptionLabel(label).split(" · ").map((part) => part.trim()).filter(Boolean);
|
||||
return {
|
||||
title: parts[0] || cleanOptionLabel(label) || "空选项",
|
||||
subtitle: parts[1] || "",
|
||||
meta: parts.slice(2).join(" · ")
|
||||
};
|
||||
}
|
||||
|
||||
function isSearchableSelect(target) {
|
||||
return target instanceof HTMLSelectElement && !target.disabled && target.size <= 1 && !target.multiple;
|
||||
}
|
||||
|
||||
function isPanelPlaceholderOption(option) {
|
||||
return Boolean(option?.disabled && option?.value === "");
|
||||
}
|
||||
|
||||
function collectOptions(selectEl) {
|
||||
return Array.from(selectEl.options || [])
|
||||
.map((option, index) => ({ option, index }))
|
||||
.filter(({ option }) => !isPanelPlaceholderOption(option))
|
||||
.map(({ option, index }) => {
|
||||
const label = cleanOptionLabel(option.textContent);
|
||||
return {
|
||||
index,
|
||||
value: option.value,
|
||||
label,
|
||||
display: splitOptionLabel(label),
|
||||
disabled: Boolean(option.disabled || option.parentElement?.disabled),
|
||||
selected: index === selectEl.selectedIndex
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function calculatePanelPosition(selectEl) {
|
||||
const rect = selectEl.getBoundingClientRect();
|
||||
const margin = 8;
|
||||
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
||||
const width = Math.max(rect.width, 180);
|
||||
const left = Math.min(Math.max(rect.left, margin), Math.max(viewportWidth - width - margin, margin));
|
||||
const top = Math.min(Math.max(rect.top, margin), Math.max(viewportHeight - rect.height - margin, margin));
|
||||
const belowSpace = viewportHeight - top - rect.height - margin - 6;
|
||||
const maxHeight = Math.max(Math.min(belowSpace, 320), 160);
|
||||
|
||||
panelPosition.value = {
|
||||
left,
|
||||
top,
|
||||
width,
|
||||
height: Math.max(rect.height, 40),
|
||||
maxHeight
|
||||
};
|
||||
}
|
||||
|
||||
function openForSelect(selectEl) {
|
||||
activeSelect.value = selectEl;
|
||||
options.value = collectOptions(selectEl);
|
||||
searchText.value = "";
|
||||
const selectedIndex = options.value.findIndex((option) => option.selected && !option.disabled);
|
||||
activeIndex.value = selectedIndex >= 0 ? selectedIndex : Math.max(options.value.findIndex((option) => !option.disabled), 0);
|
||||
calculatePanelPosition(selectEl);
|
||||
open.value = true;
|
||||
nextTick(() => {
|
||||
searchInputRef.value?.focus();
|
||||
searchInputRef.value?.select();
|
||||
});
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
open.value = false;
|
||||
activeSelect.value = null;
|
||||
options.value = [];
|
||||
searchText.value = "";
|
||||
activeIndex.value = 0;
|
||||
}
|
||||
|
||||
function moveActive(delta) {
|
||||
const rows = filteredOptions.value;
|
||||
if (!rows.length) {
|
||||
return;
|
||||
}
|
||||
let nextIndex = activeIndex.value;
|
||||
for (let step = 0; step < rows.length; step += 1) {
|
||||
nextIndex = (nextIndex + delta + rows.length) % rows.length;
|
||||
if (!rows[nextIndex]?.disabled) {
|
||||
activeIndex.value = nextIndex;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function chooseActive() {
|
||||
const option = filteredOptions.value[activeIndex.value];
|
||||
if (option) {
|
||||
selectOption(option);
|
||||
}
|
||||
}
|
||||
|
||||
function selectOption(option) {
|
||||
const selectEl = activeSelect.value;
|
||||
if (!selectEl || option.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectEl.selectedIndex = option.index;
|
||||
selectEl.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
selectEl.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
selectEl.focus({ preventScroll: true });
|
||||
closePanel();
|
||||
}
|
||||
|
||||
function handlePointerDown(event) {
|
||||
const target = event.target;
|
||||
if (panelRef.value?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
if (!isSearchableSelect(target)) {
|
||||
if (open.value) {
|
||||
closePanel();
|
||||
}
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openForSelect(target);
|
||||
}
|
||||
|
||||
function handleMouseDown(event) {
|
||||
if ("PointerEvent" in window) {
|
||||
return;
|
||||
}
|
||||
handlePointerDown(event);
|
||||
}
|
||||
|
||||
function handleKeyDown(event) {
|
||||
if (open.value) {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closePanel();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
isSearchableSelect(event.target) &&
|
||||
["Enter", " ", "ArrowDown", "ArrowUp"].includes(event.key)
|
||||
) {
|
||||
event.preventDefault();
|
||||
openForSelect(event.target);
|
||||
}
|
||||
}
|
||||
|
||||
function handleViewportChange() {
|
||||
if (open.value && activeSelect.value) {
|
||||
calculatePanelPosition(activeSelect.value);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("pointerdown", handlePointerDown, true);
|
||||
document.addEventListener("mousedown", handleMouseDown, true);
|
||||
document.addEventListener("keydown", handleKeyDown, true);
|
||||
window.addEventListener("resize", handleViewportChange);
|
||||
window.addEventListener("scroll", handleViewportChange, true);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
document.removeEventListener("mousedown", handleMouseDown, true);
|
||||
document.removeEventListener("keydown", handleKeyDown, true);
|
||||
window.removeEventListener("resize", handleViewportChange);
|
||||
window.removeEventListener("scroll", handleViewportChange, true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.global-select-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--ui-z-global-select, 3100);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.global-select-input-box {
|
||||
position: fixed;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.global-select-search-input {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
color: #606266;
|
||||
font: inherit;
|
||||
font-weight: 400;
|
||||
outline: none;
|
||||
padding: 0 34px 0 12px;
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(64, 158, 255, 0.1),
|
||||
0 6px 16px rgba(31, 45, 61, 0.12);
|
||||
}
|
||||
|
||||
.global-select-search-input:focus {
|
||||
border-color: #409eff;
|
||||
box-shadow:
|
||||
0 0 0 2px rgba(64, 158, 255, 0.12),
|
||||
0 6px 16px rgba(31, 45, 61, 0.12);
|
||||
}
|
||||
|
||||
.global-select-search-input::placeholder {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.global-select-arrow {
|
||||
position: absolute;
|
||||
right: 13px;
|
||||
top: 50%;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-bottom: 1.5px solid #c0c4cc;
|
||||
border-right: 1.5px solid #c0c4cc;
|
||||
pointer-events: none;
|
||||
transform: translateY(-65%) rotate(45deg);
|
||||
}
|
||||
|
||||
.global-select-panel {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 8px 24px rgba(31, 45, 61, 0.14);
|
||||
backdrop-filter: none;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.global-select-option-list {
|
||||
overflow: auto;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.global-select-option {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 3px 10px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: #606266;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
min-height: 34px;
|
||||
padding: 8px 14px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.global-select-option-main {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.global-select-option-sub {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.global-select-option-meta {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
justify-self: end;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.global-select-option small {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.global-select-option:hover,
|
||||
.global-select-option-active {
|
||||
color: #409eff;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.global-select-option-selected {
|
||||
color: #409eff;
|
||||
font-weight: 600;
|
||||
background: #ecf5ff;
|
||||
}
|
||||
|
||||
.global-select-option-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.global-select-empty {
|
||||
color: #909399;
|
||||
padding: 18px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<article class="kpi-card" :class="`kpi-${metric.highlight || 'normal'}`" :title="metric.note || '关键指标实时刷新'">
|
||||
<p class="kpi-label">{{ metric.label }}</p>
|
||||
<div class="kpi-value-row">
|
||||
<strong class="kpi-value">{{ metric.value }}</strong>
|
||||
<span v-if="metric.unit" class="kpi-unit">{{ metric.unit }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
metric: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<section class="metric-strip" aria-label="指标概览">
|
||||
<article v-for="metric in normalizedMetrics" :key="metric._key" class="metric-strip-item">
|
||||
<span class="metric-strip-label">{{ metric.label }}</span>
|
||||
<strong class="metric-strip-value">{{ metric.value }}</strong>
|
||||
<span v-if="metric.hint" class="metric-strip-hint">{{ metric.hint }}</span>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
metrics: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
const normalizedMetrics = computed(() =>
|
||||
props.metrics
|
||||
.filter((metric) => metric && typeof metric === "object")
|
||||
.map((metric, index) => ({
|
||||
...metric,
|
||||
label: metric.label || "未命名指标",
|
||||
value: metric.value ?? "-",
|
||||
_key: metric.key || `${metric.label || "metric"}-${index}`
|
||||
}))
|
||||
);
|
||||
</script>
|
||||
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="notification-stack" aria-live="polite" aria-atomic="false">
|
||||
<TransitionGroup name="notification-toast" tag="div" class="notification-stack-list">
|
||||
<article
|
||||
v-for="message in messages"
|
||||
:key="message.id"
|
||||
class="notification-toast-card"
|
||||
:class="`notification-toast-${message.type}`"
|
||||
@mouseenter="pauseToast(message.id)"
|
||||
@mouseleave="resumeToast(message.id)"
|
||||
>
|
||||
<div class="notification-toast-indicator"></div>
|
||||
<div class="notification-toast-body">
|
||||
<strong>{{ titleMap[message.type] || "系统提示" }}</strong>
|
||||
<p>{{ message.text }}</p>
|
||||
</div>
|
||||
<button class="notification-toast-close" type="button" aria-label="关闭提示" @click="removeToast(message.id)">
|
||||
×
|
||||
</button>
|
||||
</article>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useNotifications } from "../utils/notifications";
|
||||
|
||||
const titleMap = {
|
||||
success: "操作成功",
|
||||
error: "操作失败",
|
||||
info: "系统提示"
|
||||
};
|
||||
|
||||
const { messages, removeToast, pauseToast, resumeToast } = useNotifications();
|
||||
</script>
|
||||
@@ -0,0 +1,553 @@
|
||||
<template>
|
||||
<div
|
||||
ref="mindMapViewport"
|
||||
class="org-mind-map"
|
||||
:class="{ 'org-mind-map--panning': isCanvasPanning }"
|
||||
@mousedown="startCanvasPan"
|
||||
>
|
||||
<svg
|
||||
class="org-mind-map-canvas"
|
||||
:viewBox="`0 0 ${canvasWidth} ${canvasHeight}`"
|
||||
:style="scaledCanvasStyle"
|
||||
role="img"
|
||||
aria-label="组织权限脑图"
|
||||
>
|
||||
<g class="org-mind-map-connectors" fill="none">
|
||||
<path
|
||||
v-for="connector in layout.connectors"
|
||||
:key="connector.key"
|
||||
class="org-mind-map-connector"
|
||||
:d="`M ${connector.fromX} ${connector.fromY} H ${connector.midX} V ${connector.toY} H ${connector.toX}`"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<g
|
||||
v-for="node in layout.nodes"
|
||||
:key="node.key"
|
||||
:class="['org-mind-map-node', { 'org-mind-map-node--employee': node.isEmployee }]"
|
||||
:transform="`translate(${node.x} ${node.y})`"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
@click="emit('select-node', node.raw)"
|
||||
@contextmenu.prevent.stop="emit('node-contextmenu', { node: node.raw, x: $event.clientX, y: $event.clientY })"
|
||||
@keyup.enter="emit('select-node', node.raw)"
|
||||
>
|
||||
<title>{{ node.title }} · {{ node.subtitle }}</title>
|
||||
<rect class="org-mind-map-card" :width="node.width" :height="NODE_HEIGHT" rx="16" />
|
||||
<text class="org-mind-map-title" x="18" y="31">{{ node.displayTitle }}</text>
|
||||
<text class="org-mind-map-subtitle" x="18" y="57">{{ node.displaySubtitle }}</text>
|
||||
<text
|
||||
v-if="!node.isEmployee && node.employee_count"
|
||||
class="org-mind-map-employee-count"
|
||||
x="18"
|
||||
y="76"
|
||||
>
|
||||
{{ node.employee_count }} 人
|
||||
</text>
|
||||
|
||||
<g
|
||||
v-if="!node.isEmployee && node.employee_count"
|
||||
class="org-mind-map-employee-toggle"
|
||||
:transform="`translate(${node.width - 38} 14)`"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click.stop="toggleEmployeeLeaves(node.raw)"
|
||||
@contextmenu.prevent.stop
|
||||
@keyup.enter.stop="toggleEmployeeLeaves(node.raw)"
|
||||
>
|
||||
<circle r="15" cx="15" cy="15" />
|
||||
<text x="15" y="20" text-anchor="middle">{{ isEmployeeExpanded(node.raw) ? "−" : "+" }}</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
nodes: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
zoomScale: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
fitRequestKey: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(["select-node", "node-contextmenu", "toggle-employee-leaves", "fit-scale"]);
|
||||
|
||||
const NODE_MIN_WIDTH = 190;
|
||||
const NODE_MAX_WIDTH = 420;
|
||||
const NODE_HEIGHT = 92;
|
||||
const TEXT_PADDING_X = 40;
|
||||
const TOGGLE_RESERVE_WIDTH = 52;
|
||||
const COLUMN_GAP = 150;
|
||||
const ROW_GAP = 34;
|
||||
const CANVAS_PADDING = 24;
|
||||
|
||||
const COLUMN_WIDTH = NODE_MAX_WIDTH + COLUMN_GAP;
|
||||
const ROW_HEIGHT = NODE_HEIGHT + ROW_GAP;
|
||||
const ROOT_GROUP_GAP = 1;
|
||||
const FIT_SCALE_MIN = 0.12;
|
||||
const FIT_SCALE_MAX = 1;
|
||||
|
||||
const mindMapViewport = ref(null);
|
||||
const isCanvasPanning = ref(false);
|
||||
const canvasPan = ref({
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
scrollLeft: 0,
|
||||
scrollTop: 0
|
||||
});
|
||||
const expandedEmployeeNodeIds = ref(new Set());
|
||||
const collapsedEmployeeNodeIds = computed(() => {
|
||||
const collapsed = new Set();
|
||||
for (const node of flattenOrgNodes(props.nodes)) {
|
||||
if ((node.employees || []).length && !expandedEmployeeNodeIds.value.has(node.node_id)) {
|
||||
collapsed.add(node.node_id);
|
||||
}
|
||||
}
|
||||
return collapsed;
|
||||
});
|
||||
|
||||
function flattenOrgNodes(nodes) {
|
||||
const result = [];
|
||||
for (const node of nodes || []) {
|
||||
result.push(node);
|
||||
result.push(...flattenOrgNodes(node.children || []));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function isEmployeeExpanded(node) {
|
||||
return expandedEmployeeNodeIds.value.has(node?.node_id);
|
||||
}
|
||||
|
||||
function toggleEmployeeLeaves(node) {
|
||||
if (!node?.node_id) {
|
||||
return;
|
||||
}
|
||||
const next = new Set(expandedEmployeeNodeIds.value);
|
||||
if (next.has(node.node_id)) {
|
||||
next.delete(node.node_id);
|
||||
} else {
|
||||
next.add(node.node_id);
|
||||
}
|
||||
expandedEmployeeNodeIds.value = next;
|
||||
emit("toggle-employee-leaves", node);
|
||||
}
|
||||
|
||||
function shouldIgnoreCanvasPan(event) {
|
||||
const target = event.target;
|
||||
return Boolean(
|
||||
target?.closest?.(".org-mind-map-node") ||
|
||||
target?.closest?.(".org-mind-map-employee-toggle")
|
||||
);
|
||||
}
|
||||
|
||||
function startCanvasPan(event) {
|
||||
if (event.button !== 0 || shouldIgnoreCanvasPan(event)) {
|
||||
return;
|
||||
}
|
||||
const viewport = mindMapViewport.value;
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
isCanvasPanning.value = true;
|
||||
canvasPan.value = {
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
scrollLeft: viewport.scrollLeft,
|
||||
scrollTop: viewport.scrollTop
|
||||
};
|
||||
window.addEventListener("mousemove", moveCanvasPan);
|
||||
window.addEventListener("mouseup", endCanvasPan);
|
||||
}
|
||||
|
||||
function moveCanvasPan(event) {
|
||||
if (!isCanvasPanning.value) {
|
||||
return;
|
||||
}
|
||||
const viewport = mindMapViewport.value;
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
const deltaX = event.clientX - canvasPan.value.startX;
|
||||
const deltaY = event.clientY - canvasPan.value.startY;
|
||||
event.preventDefault();
|
||||
viewport.scrollLeft = canvasPan.value.scrollLeft - deltaX;
|
||||
viewport.scrollTop = canvasPan.value.scrollTop - deltaY;
|
||||
}
|
||||
|
||||
function endCanvasPan() {
|
||||
isCanvasPanning.value = false;
|
||||
window.removeEventListener("mousemove", moveCanvasPan);
|
||||
window.removeEventListener("mouseup", endCanvasPan);
|
||||
}
|
||||
|
||||
function estimateTextWidth(value, fontSize = 16) {
|
||||
return Array.from(String(value || "")).reduce((width, char) => {
|
||||
if (/[\u4e00-\u9fff\uff00-\uffef]/.test(char)) {
|
||||
return width + fontSize;
|
||||
}
|
||||
if (/\s/.test(char)) {
|
||||
return width + fontSize * 0.35;
|
||||
}
|
||||
if (/[A-Z0-9]/.test(char)) {
|
||||
return width + fontSize * 0.62;
|
||||
}
|
||||
return width + fontSize * 0.55;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function fitTextToWidth(value, maxWidth, fontSize = 16) {
|
||||
const text = String(value || "");
|
||||
if (estimateTextWidth(text, fontSize) <= maxWidth) {
|
||||
return text;
|
||||
}
|
||||
let result = "";
|
||||
for (const char of Array.from(text)) {
|
||||
const next = `${result}${char}`;
|
||||
if (estimateTextWidth(`${next}...`, fontSize) > maxWidth) {
|
||||
return `${result}...`;
|
||||
}
|
||||
result = next;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function computeNodeWidth({ title, subtitle, hasEmployeeToggle }) {
|
||||
const toggleReserve = hasEmployeeToggle ? TOGGLE_RESERVE_WIDTH : 0;
|
||||
const titleWidth = estimateTextWidth(title, 16) + TEXT_PADDING_X + toggleReserve;
|
||||
const subtitleWidth = estimateTextWidth(subtitle, 13) + TEXT_PADDING_X;
|
||||
return clamp(Math.ceil(Math.max(titleWidth, subtitleWidth)), NODE_MIN_WIDTH, NODE_MAX_WIDTH);
|
||||
}
|
||||
|
||||
function toVisualChildren(node) {
|
||||
const orgChildren = Array.isArray(node.children) ? node.children : [];
|
||||
const shouldShowEmployees = Boolean(node.node_id) && !collapsedEmployeeNodeIds.value.has(node.node_id);
|
||||
const employeeLeaves = shouldShowEmployees && Array.isArray(node.employees)
|
||||
? node.employees.map((employee) => ({
|
||||
...employee,
|
||||
parent_node_id: node.node_id,
|
||||
parent_node_label: node.node_label,
|
||||
node_type: "EMPLOYEE",
|
||||
node_label: employee.employee_name,
|
||||
children: [],
|
||||
employees: []
|
||||
}))
|
||||
: [];
|
||||
return [...orgChildren, ...employeeLeaves];
|
||||
}
|
||||
|
||||
function normalizeNode(node, index, parentKey = "root") {
|
||||
const isEmployee = node.node_type === "EMPLOYEE" || Boolean(node.employee_id && !node.node_id);
|
||||
const id = node.node_id ?? node.employee_id ?? `${parentKey}-${index}`;
|
||||
const key = `${isEmployee ? "employee" : "org"}-${id}`;
|
||||
const managerSubtitle = Array.isArray(node.manager_names) && node.manager_names.length
|
||||
? node.manager_names.join("、")
|
||||
: node.manager_name || node.subtitle || "未设置主管";
|
||||
const title = node.node_label || node.employee_name || node.title || "未命名节点";
|
||||
const subtitle = isEmployee ? node.mobile || "未设置电话" : managerSubtitle;
|
||||
const employeeCount = Array.isArray(node.employees) ? node.employees.length : 0;
|
||||
const width = computeNodeWidth({
|
||||
title,
|
||||
subtitle,
|
||||
hasEmployeeToggle: !isEmployee && employeeCount > 0
|
||||
});
|
||||
return {
|
||||
raw: node,
|
||||
key,
|
||||
isEmployee,
|
||||
width,
|
||||
employee_count: employeeCount,
|
||||
title,
|
||||
subtitle,
|
||||
displayTitle: fitTextToWidth(title, width - TEXT_PADDING_X - (!isEmployee && employeeCount ? TOGGLE_RESERVE_WIDTH : 0), 16),
|
||||
displaySubtitle: fitTextToWidth(subtitle, width - TEXT_PADDING_X, 13),
|
||||
children: toVisualChildren(node).map((child, childIndex) => normalizeNode(child, childIndex, key))
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeNodes(nodes) {
|
||||
return nodes.map((node, index) => normalizeNode(node, index));
|
||||
}
|
||||
|
||||
function measureSubtree(node) {
|
||||
if (!node.children.length) {
|
||||
node.subtreeSpan = 1;
|
||||
return node.subtreeSpan;
|
||||
}
|
||||
|
||||
node.subtreeSpan = node.children.reduce((total, child) => total + measureSubtree(child), 0);
|
||||
return node.subtreeSpan;
|
||||
}
|
||||
|
||||
function segmentKey(segment) {
|
||||
const x1 = Math.round(segment.x1 * 100) / 100;
|
||||
const y1 = Math.round(segment.y1 * 100) / 100;
|
||||
const x2 = Math.round(segment.x2 * 100) / 100;
|
||||
const y2 = Math.round(segment.y2 * 100) / 100;
|
||||
const start = `${Math.min(x1, x2)},${Math.min(y1, y2)}`;
|
||||
const end = `${Math.max(x1, x2)},${Math.max(y1, y2)}`;
|
||||
return `${start}-${end}`;
|
||||
}
|
||||
|
||||
function connectorSegments(parent, child, laneX, trunkFromY, trunkToY) {
|
||||
const parentEdgeX = parent.x + parent.width;
|
||||
const parentCenterY = parent.y + NODE_HEIGHT / 2;
|
||||
const childEdgeX = child.x;
|
||||
const childCenterY = child.y + NODE_HEIGHT / 2;
|
||||
|
||||
// Orthogonal connector logic: edge stems stay outside cards, trunk stays in the column gutter.
|
||||
return [
|
||||
{ x1: parentEdgeX, y1: parentCenterY, x2: laneX, y2: parentCenterY },
|
||||
{ x1: laneX, y1: trunkFromY, x2: laneX, y2: trunkToY },
|
||||
{ x1: laneX, y1: childCenterY, x2: childEdgeX, y2: childCenterY }
|
||||
];
|
||||
}
|
||||
|
||||
function toConnector(segment) {
|
||||
return {
|
||||
key: segmentKey(segment),
|
||||
fromX: segment.x1,
|
||||
fromY: segment.y1,
|
||||
midX: segment.x2,
|
||||
toY: segment.y2,
|
||||
toX: segment.x2
|
||||
};
|
||||
}
|
||||
|
||||
function layoutTree(rootNodes) {
|
||||
const nodes = [];
|
||||
const connectors = [];
|
||||
const connectorMap = new Map();
|
||||
let nextLeafRow = 0;
|
||||
let maxDepth = 0;
|
||||
|
||||
function placeNode(node, depth) {
|
||||
maxDepth = Math.max(maxDepth, depth);
|
||||
|
||||
if (!node.children.length) {
|
||||
const row = nextLeafRow;
|
||||
nextLeafRow += 1;
|
||||
const layoutNode = {
|
||||
...node,
|
||||
depth,
|
||||
row,
|
||||
x: CANVAS_PADDING + depth * COLUMN_WIDTH,
|
||||
y: CANVAS_PADDING + row * ROW_HEIGHT
|
||||
};
|
||||
nodes.push(layoutNode);
|
||||
return layoutNode;
|
||||
}
|
||||
|
||||
const childNodes = node.children.map((child) => placeNode(child, depth + 1));
|
||||
const firstChild = childNodes[0];
|
||||
const lastChild = childNodes[childNodes.length - 1];
|
||||
const row = (firstChild.row + lastChild.row) / 2;
|
||||
const layoutNode = {
|
||||
...node,
|
||||
depth,
|
||||
row,
|
||||
x: CANVAS_PADDING + depth * COLUMN_WIDTH,
|
||||
y: CANVAS_PADDING + row * ROW_HEIGHT
|
||||
};
|
||||
nodes.push(layoutNode);
|
||||
|
||||
const laneX = layoutNode.x + layoutNode.width + COLUMN_GAP / 2;
|
||||
const childCenterYs = childNodes.map((child) => child.y + NODE_HEIGHT / 2);
|
||||
const trunkFromY = Math.min(...childCenterYs);
|
||||
const trunkToY = Math.max(...childCenterYs);
|
||||
|
||||
childNodes.forEach((child) => {
|
||||
connectorSegments(layoutNode, child, laneX, trunkFromY, trunkToY).forEach((segment) => {
|
||||
const key = segmentKey(segment);
|
||||
if (!connectorMap.has(key)) {
|
||||
connectorMap.set(key, true);
|
||||
connectors.push(toConnector(segment));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return layoutNode;
|
||||
}
|
||||
|
||||
rootNodes.forEach((rootNode, index) => {
|
||||
measureSubtree(rootNode);
|
||||
placeNode(rootNode, 0);
|
||||
if (index < rootNodes.length - 1) {
|
||||
nextLeafRow += ROOT_GROUP_GAP;
|
||||
}
|
||||
});
|
||||
|
||||
const maxRow = Math.max(0, ...nodes.map((node) => node.row));
|
||||
|
||||
return {
|
||||
nodes,
|
||||
connectors,
|
||||
width: CANVAS_PADDING * 2 + NODE_MAX_WIDTH + maxDepth * COLUMN_WIDTH,
|
||||
height: CANVAS_PADDING * 2 + NODE_HEIGHT + maxRow * ROW_HEIGHT
|
||||
};
|
||||
}
|
||||
|
||||
const layout = computed(() => layoutTree(normalizeNodes(props.nodes)));
|
||||
const canvasWidth = computed(() => Math.max(760, layout.value.width));
|
||||
const canvasHeight = computed(() => Math.max(460, layout.value.height));
|
||||
const scaledCanvasStyle = computed(() => ({
|
||||
width: `${canvasWidth.value * props.zoomScale}px`,
|
||||
height: `${canvasHeight.value * props.zoomScale}px`
|
||||
}));
|
||||
|
||||
function centerCanvasInViewport() {
|
||||
const viewport = mindMapViewport.value;
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
viewport.scrollLeft = Math.max(0, (viewport.scrollWidth - viewport.clientWidth) / 2);
|
||||
viewport.scrollTop = Math.max(0, (viewport.scrollHeight - viewport.clientHeight) / 2);
|
||||
}
|
||||
|
||||
async function fitCanvasToViewport() {
|
||||
await nextTick();
|
||||
const viewport = mindMapViewport.value;
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
const availableWidth = Math.max(1, viewport.clientWidth - 24);
|
||||
const availableHeight = Math.max(1, viewport.clientHeight - 24);
|
||||
const widthScale = availableWidth / canvasWidth.value;
|
||||
const heightScale = availableHeight / canvasHeight.value;
|
||||
const nextScale = clamp(Math.min(widthScale, heightScale), FIT_SCALE_MIN, FIT_SCALE_MAX);
|
||||
emit("fit-scale", Number(nextScale.toFixed(2)));
|
||||
requestAnimationFrame(centerCanvasInViewport);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.fitRequestKey,
|
||||
() => {
|
||||
if (props.fitRequestKey > 0) {
|
||||
fitCanvasToViewport();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
endCanvasPan();
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
fitCanvasToViewport
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.org-mind-map {
|
||||
width: 100%;
|
||||
height: clamp(520px, 68vh, 760px);
|
||||
min-height: 460px;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
border: 1px solid rgba(58, 82, 105, 0.18);
|
||||
border-radius: 24px;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(58, 82, 105, 0.06) 1px, transparent 1px) 0 0 / 28px 28px,
|
||||
linear-gradient(0deg, rgba(58, 82, 105, 0.05) 1px, transparent 1px) 0 0 / 28px 28px,
|
||||
radial-gradient(circle at 12% 12%, rgba(14, 165, 233, 0.12), transparent 30%),
|
||||
linear-gradient(135deg, rgba(248, 250, 252, 0.98), rgba(232, 239, 247, 0.72));
|
||||
background-size:
|
||||
28px 28px,
|
||||
28px 28px,
|
||||
auto,
|
||||
auto;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.88);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.org-mind-map--panning {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.org-mind-map-canvas {
|
||||
display: block;
|
||||
transition: width 0.18s ease, height 0.18s ease;
|
||||
}
|
||||
|
||||
.org-mind-map-connector {
|
||||
stroke: rgba(79, 115, 148, 0.7);
|
||||
stroke-width: 2.25;
|
||||
stroke-linecap: square;
|
||||
stroke-linejoin: miter;
|
||||
filter: drop-shadow(0 1px 0 rgba(255, 255, 255, 0.76));
|
||||
}
|
||||
|
||||
.org-mind-map-node {
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.org-mind-map-node:focus .org-mind-map-card,
|
||||
.org-mind-map-node:hover .org-mind-map-card {
|
||||
stroke: #0ea5e9;
|
||||
stroke-width: 2;
|
||||
filter: drop-shadow(0 18px 26px rgba(37, 99, 235, 0.18));
|
||||
}
|
||||
|
||||
.org-mind-map-card {
|
||||
fill: rgba(255, 255, 255, 0.96);
|
||||
stroke: rgba(58, 82, 105, 0.2);
|
||||
filter: drop-shadow(0 14px 22px rgba(15, 23, 42, 0.11));
|
||||
}
|
||||
|
||||
.org-mind-map-node--employee .org-mind-map-card {
|
||||
fill: rgba(240, 253, 250, 0.96);
|
||||
stroke: rgba(20, 184, 166, 0.34);
|
||||
}
|
||||
|
||||
.org-mind-map-title {
|
||||
fill: #142033;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.org-mind-map-subtitle,
|
||||
.org-mind-map-employee-count {
|
||||
fill: #647587;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.org-mind-map-node--employee .org-mind-map-title {
|
||||
fill: #0f766e;
|
||||
}
|
||||
|
||||
.org-mind-map-employee-toggle {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.org-mind-map-employee-toggle circle {
|
||||
fill: #e0f2fe;
|
||||
stroke: rgba(14, 165, 233, 0.38);
|
||||
}
|
||||
|
||||
.org-mind-map-employee-toggle text {
|
||||
fill: #0369a1;
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,865 @@
|
||||
<template>
|
||||
<section class="org-permission-tree-card">
|
||||
<aside class="org-tree-panel" aria-label="组织树">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="eyebrow">组织架构</p>
|
||||
<h3>组织树</h3>
|
||||
</div>
|
||||
<span class="status-pill">{{ organizationCount }} 个组织节点</span>
|
||||
</div>
|
||||
|
||||
<label class="org-tree-search">
|
||||
<span aria-hidden="true">⌕</span>
|
||||
<input v-model.trim="treeSearchKeyword" type="search" placeholder="请输入组织名称" />
|
||||
</label>
|
||||
|
||||
<div v-if="displayedOrganizationNodes.length" class="org-tree-list">
|
||||
<OrgTreeBranch
|
||||
v-for="node in displayedOrganizationNodes"
|
||||
:key="nodeKey(node)"
|
||||
:node="node"
|
||||
:level="0"
|
||||
:selected-id="effectiveSelectedNodeId"
|
||||
:expanded-ids="expandedNodeIds"
|
||||
@toggle="toggleNode"
|
||||
@select="selectNode"
|
||||
@open-menu="openNodeMenu"
|
||||
/>
|
||||
</div>
|
||||
<p v-else class="empty-state">{{ treeSearchKeyword ? "没有匹配的组织节点" : "暂无组织节点" }}</p>
|
||||
</aside>
|
||||
|
||||
<section class="employee-panel" aria-label="当前组织人员">
|
||||
<div class="panel-head employee-head">
|
||||
<div>
|
||||
<p class="eyebrow">组织人员</p>
|
||||
<h3>{{ selectedNodeLabel }}</h3>
|
||||
<p class="permission-note">当前节点人员共 {{ selectedEmployees.length }} 人</p>
|
||||
</div>
|
||||
<button
|
||||
class="primary-button"
|
||||
type="button"
|
||||
:disabled="!canAddEmployee"
|
||||
@click="addEmployee"
|
||||
>
|
||||
新增人员
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label class="employee-search-field">
|
||||
<span>搜索</span>
|
||||
<input v-model="searchKeyword" type="search" placeholder="搜索人员姓名或手机号" />
|
||||
</label>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table class="data-table compact-table org-employee-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>姓名</th>
|
||||
<th>电话号码 / 账号</th>
|
||||
<th>岗位</th>
|
||||
<th>角色</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="employee in paginatedEmployees" :key="employeeKey(employee)">
|
||||
<td>{{ employeeName(employee) }}</td>
|
||||
<td>{{ employeePhone(employee) }}</td>
|
||||
<td>{{ employeePosition(employee) }}</td>
|
||||
<td>{{ employeeRoles(employee) }}</td>
|
||||
<td>
|
||||
<StatusBadge :value="employee?.status" :text="employeeStatus(employee)" />
|
||||
</td>
|
||||
<td class="action-row">
|
||||
<button class="ghost-button" type="button" @click="authorizeEmployee(employee)">授权</button>
|
||||
<button class="ghost-button danger" type="button" @click="removeEmployee(employee)">移除</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!filteredEmployees.length">
|
||||
<td colspan="6" class="empty-row">{{ selectedEmployees.length ? "没有符合条件的人员" : "当前节点暂无人员" }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<PaginationBar v-model:page="employeePage" v-model:page-size="employeePageSize" :total="filteredEmployees.length" />
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, defineComponent, h, ref, watch } from "vue";
|
||||
|
||||
import PaginationBar from "./PaginationBar.vue";
|
||||
import StatusBadge from "./StatusBadge.vue";
|
||||
import { usePagination } from "../utils/pagination";
|
||||
|
||||
const props = defineProps({
|
||||
nodes: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
selectedNodeId: {
|
||||
type: [Number, String],
|
||||
default: ""
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
"select-node",
|
||||
"node-contextmenu",
|
||||
"add-employee",
|
||||
"authorize-employee",
|
||||
"remove-employee"
|
||||
]);
|
||||
|
||||
const OrgTreeBranch = defineComponent({
|
||||
name: "OrgTreeBranch",
|
||||
props: {
|
||||
node: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
level: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
selectedId: {
|
||||
type: [Number, String],
|
||||
default: ""
|
||||
},
|
||||
expandedIds: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ["toggle", "select", "open-menu"],
|
||||
setup(branchProps, { emit: branchEmit }) {
|
||||
const childOrganizations = computed(() => organizationChildren(branchProps.node));
|
||||
const currentNodeId = computed(() => getNodeId(branchProps.node));
|
||||
const hasChildren = computed(() => childOrganizations.value.length > 0);
|
||||
const isExpanded = computed(() => branchProps.expandedIds.has(String(currentNodeId.value)));
|
||||
const isSelected = computed(() => sameId(currentNodeId.value, branchProps.selectedId));
|
||||
|
||||
function selectCurrent() {
|
||||
branchEmit("select", branchProps.node);
|
||||
}
|
||||
|
||||
function toggleCurrent(event) {
|
||||
event.stopPropagation();
|
||||
branchEmit("toggle", branchProps.node);
|
||||
}
|
||||
|
||||
function openMenu(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
branchEmit("open-menu", {
|
||||
node: branchProps.node,
|
||||
x: event.clientX,
|
||||
y: event.clientY
|
||||
});
|
||||
}
|
||||
|
||||
return () =>
|
||||
h("div", { class: "org-tree-branch" }, [
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
class: ["org-tree-node", { active: isSelected.value }],
|
||||
style: { "--tree-level": branchProps.level },
|
||||
onClick: selectCurrent,
|
||||
onContextmenu: openMenu
|
||||
},
|
||||
[
|
||||
h(
|
||||
"span",
|
||||
{
|
||||
class: ["tree-toggle", { hidden: !hasChildren.value }],
|
||||
onClick: toggleCurrent
|
||||
},
|
||||
hasChildren.value ? (isExpanded.value ? "⌄" : "›") : ""
|
||||
),
|
||||
h("span", { class: ["tree-node-icon", `tree-node-icon-${String(branchProps.node?.node_type || "ORG").toLowerCase()}`] }, nodeTypeShortLabel(branchProps.node?.node_type)),
|
||||
h("span", { class: "tree-node-main" }, [
|
||||
h("strong", null, nodeLabel(branchProps.node)),
|
||||
h("small", null, `${nodeTypeLabel(branchProps.node?.node_type)} · ${employeeCount(branchProps.node)} 人`)
|
||||
])
|
||||
]
|
||||
),
|
||||
hasChildren.value && isExpanded.value
|
||||
? h(
|
||||
"div",
|
||||
{ class: "org-tree-children" },
|
||||
childOrganizations.value.map((child) =>
|
||||
h(OrgTreeBranch, {
|
||||
key: nodeKey(child),
|
||||
node: child,
|
||||
level: branchProps.level + 1,
|
||||
selectedId: branchProps.selectedId,
|
||||
expandedIds: branchProps.expandedIds,
|
||||
onToggle: (node) => branchEmit("toggle", node),
|
||||
onSelect: (node) => branchEmit("select", node),
|
||||
onOpenMenu: (payload) => branchEmit("open-menu", payload)
|
||||
})
|
||||
)
|
||||
)
|
||||
: null
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
const searchKeyword = ref("");
|
||||
const treeSearchKeyword = ref("");
|
||||
const expandedNodeIds = ref(new Set());
|
||||
|
||||
const organizationNodes = computed(() => (Array.isArray(props.nodes) ? props.nodes : []).filter(isOrganizationNode));
|
||||
const displayedOrganizationNodes = computed(() => filterOrgTreeByKeyword(organizationNodes.value, treeSearchKeyword.value));
|
||||
const flattenedOrganizations = computed(() => flattenOrganizations(organizationNodes.value));
|
||||
const organizationCount = computed(() => flattenedOrganizations.value.length);
|
||||
const firstRootNode = computed(() => organizationNodes.value[0] || null);
|
||||
const requestedNode = computed(() => findNodeById(organizationNodes.value, props.selectedNodeId));
|
||||
const selectedNode = computed(() => requestedNode.value || firstRootNode.value);
|
||||
const effectiveSelectedNodeId = computed(() => getNodeId(selectedNode.value) ?? "");
|
||||
const selectedNodeLabel = computed(() => selectedNode.value ? nodeLabel(selectedNode.value) : "未选择组织");
|
||||
const selectedEmployees = computed(() => Array.isArray(selectedNode.value?.employees) ? selectedNode.value.employees : []);
|
||||
const canAddEmployee = computed(() => ["DEPARTMENT", "GROUP"].includes(selectedNode.value?.node_type));
|
||||
const filteredEmployees = computed(() => {
|
||||
const keyword = searchKeyword.value.trim().toLowerCase();
|
||||
if (!keyword) {
|
||||
return selectedEmployees.value;
|
||||
}
|
||||
return selectedEmployees.value.filter((employee) =>
|
||||
[employeeName(employee), employeePhone(employee), employee.employee_code, employee.username]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(keyword)
|
||||
);
|
||||
});
|
||||
|
||||
const {
|
||||
page: employeePage,
|
||||
pageSize: employeePageSize,
|
||||
rows: paginatedEmployees
|
||||
} = usePagination(filteredEmployees);
|
||||
|
||||
watch(
|
||||
organizationNodes,
|
||||
(nodes) => {
|
||||
expandedNodeIds.value = new Set(flattenOrganizations(nodes).map((node) => String(getNodeId(node))));
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(effectiveSelectedNodeId, () => {
|
||||
searchKeyword.value = "";
|
||||
});
|
||||
|
||||
function organizationChildren(node) {
|
||||
return (Array.isArray(node?.children) ? node.children : []).filter(isOrganizationNode);
|
||||
}
|
||||
|
||||
function filterOrgTreeByKeyword(nodes, keyword) {
|
||||
const normalizedKeyword = String(keyword || "").trim().toLowerCase();
|
||||
if (!normalizedKeyword) {
|
||||
return nodes;
|
||||
}
|
||||
return (nodes || [])
|
||||
.map((node) => {
|
||||
const children = filterOrgTreeByKeyword(organizationChildren(node), normalizedKeyword);
|
||||
const text = [nodeLabel(node), nodeTypeLabel(node?.node_type), node?.dept_code, node?.manager_name, ...(node?.manager_names || [])]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
if (text.includes(normalizedKeyword) || children.length) {
|
||||
return {
|
||||
...node,
|
||||
children
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function isOrganizationNode(node) {
|
||||
return Boolean(node) && node.node_type !== "EMPLOYEE" && !node.employee_id;
|
||||
}
|
||||
|
||||
function flattenOrganizations(nodes) {
|
||||
return (nodes || []).flatMap((node) => [node, ...flattenOrganizations(organizationChildren(node))]);
|
||||
}
|
||||
|
||||
function findNodeById(nodes, id) {
|
||||
if (id === "" || id === null || id === undefined) {
|
||||
return null;
|
||||
}
|
||||
for (const node of nodes || []) {
|
||||
if (sameId(getNodeId(node), id)) {
|
||||
return node;
|
||||
}
|
||||
const matchedChild = findNodeById(organizationChildren(node), id);
|
||||
if (matchedChild) {
|
||||
return matchedChild;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sameId(left, right) {
|
||||
return String(left ?? "") === String(right ?? "");
|
||||
}
|
||||
|
||||
function getNodeId(node) {
|
||||
return node?.node_id ?? node?.id ?? node?.value ?? "";
|
||||
}
|
||||
|
||||
function nodeKey(node) {
|
||||
return getNodeId(node) || nodeLabel(node);
|
||||
}
|
||||
|
||||
function nodeLabel(node) {
|
||||
return node?.node_label || node?.label || node?.name || "未命名组织";
|
||||
}
|
||||
|
||||
function nodeTypeLabel(type) {
|
||||
return {
|
||||
COMPANY: "总公司",
|
||||
BRANCH: "分公司",
|
||||
DEPARTMENT: "部门",
|
||||
GROUP: "小组"
|
||||
}[type] || "组织";
|
||||
}
|
||||
|
||||
function nodeTypeShortLabel(type) {
|
||||
return {
|
||||
COMPANY: "总",
|
||||
BRANCH: "分",
|
||||
DEPARTMENT: "部",
|
||||
GROUP: "组"
|
||||
}[type] || "组";
|
||||
}
|
||||
|
||||
function employeeCount(node) {
|
||||
const employeeKeys = new Set();
|
||||
collectEmployeeKeys(node, employeeKeys);
|
||||
return employeeKeys.size;
|
||||
}
|
||||
|
||||
function collectEmployeeKeys(node, employeeKeys) {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
for (const employee of Array.isArray(node.employees) ? node.employees : []) {
|
||||
employeeKeys.add(String(employeeKey(employee)));
|
||||
}
|
||||
for (const child of Array.isArray(node.children) ? node.children : []) {
|
||||
if (isOrganizationNode(child)) {
|
||||
collectEmployeeKeys(child, employeeKeys);
|
||||
} else if (child?.employee_id) {
|
||||
employeeKeys.add(String(employeeKey(child)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleNode(node) {
|
||||
const targetId = String(getNodeId(node));
|
||||
const next = new Set(expandedNodeIds.value);
|
||||
if (next.has(targetId)) {
|
||||
next.delete(targetId);
|
||||
} else {
|
||||
next.add(targetId);
|
||||
}
|
||||
expandedNodeIds.value = next;
|
||||
}
|
||||
|
||||
function selectNode(node) {
|
||||
emit("select-node", node);
|
||||
}
|
||||
|
||||
function openNodeMenu(payload) {
|
||||
emit("node-contextmenu", payload);
|
||||
}
|
||||
|
||||
function employeeName(employee) {
|
||||
return employee?.employee_name || employee?.display_name || employee?.name || employee?.username || "未命名人员";
|
||||
}
|
||||
|
||||
function employeePhone(employee) {
|
||||
return employee?.mobile || employee?.phone || employee?.username || "未设置电话号码";
|
||||
}
|
||||
|
||||
function employeePosition(employee) {
|
||||
return employee?.job_title || employee?.position || employee?.post_name || employee?.title || "未设置岗位";
|
||||
}
|
||||
|
||||
function employeeRoles(employee) {
|
||||
if (Array.isArray(employee?.role_names) && employee.role_names.length) {
|
||||
return employee.role_names.join("、");
|
||||
}
|
||||
if (Array.isArray(employee?.roles) && employee.roles.length) {
|
||||
return employee.roles.map((role) => role.role_name || role.name || role.role_code || role).join("、");
|
||||
}
|
||||
return employee?.role_name || employee?.role || "未设置角色";
|
||||
}
|
||||
|
||||
function employeeStatus(employee) {
|
||||
if (employee?.status === "ACTIVE") {
|
||||
return "启用";
|
||||
}
|
||||
if (employee?.status === "INACTIVE") {
|
||||
return "停用";
|
||||
}
|
||||
return "未设置状态";
|
||||
}
|
||||
|
||||
function employeeKey(employee) {
|
||||
return employee?.employee_id || employee?.user_id || employee?.username || employeeName(employee);
|
||||
}
|
||||
|
||||
function employeeWithParent(employee) {
|
||||
return {
|
||||
...employee,
|
||||
parent_node_id: getNodeId(selectedNode.value),
|
||||
parent_node_label: nodeLabel(selectedNode.value)
|
||||
};
|
||||
}
|
||||
|
||||
function addEmployee() {
|
||||
if (canAddEmployee.value) {
|
||||
emit("add-employee", selectedNode.value);
|
||||
}
|
||||
}
|
||||
|
||||
function authorizeEmployee(employee) {
|
||||
emit("authorize-employee", employeeWithParent(employee));
|
||||
}
|
||||
|
||||
function removeEmployee(employee) {
|
||||
emit("remove-employee", employeeWithParent(employee));
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.org-permission-tree-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(300px, 0.32fr) minmax(680px, 1fr);
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
min-width: 1040px;
|
||||
min-height: min(620px, calc(100vh - 270px));
|
||||
}
|
||||
|
||||
.org-tree-panel,
|
||||
.employee-panel {
|
||||
border: 1px solid #dbe3ef;
|
||||
border-radius: 18px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.org-tree-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 14px 10px 14px 12px;
|
||||
}
|
||||
|
||||
.employee-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.employee-head {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 6px;
|
||||
color: #2563eb;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.panel-head h3 {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.permission-note,
|
||||
.empty-state {
|
||||
margin: 6px 0 0;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
border-radius: 999px;
|
||||
padding: 6px 10px;
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.org-tree-search {
|
||||
display: grid;
|
||||
grid-template-columns: 22px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 36px;
|
||||
margin-bottom: 12px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #d8e0eb;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
color: #8b97a8;
|
||||
transition: border-color 0.16s ease, box-shadow 0.16s ease;
|
||||
}
|
||||
|
||||
.org-tree-search:focus-within {
|
||||
border-color: #409eff;
|
||||
box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.12);
|
||||
}
|
||||
|
||||
.org-tree-search span {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
color: #9aa7b8;
|
||||
font-size: 17px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.org-tree-search input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #303847;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.org-tree-search input::placeholder {
|
||||
color: #a8b1bf;
|
||||
}
|
||||
|
||||
.org-tree-list {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 2px 2px 8px 0;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.org-tree-branch {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.org-tree-node {
|
||||
display: grid;
|
||||
grid-template-columns: 18px 24px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
padding: 7px 8px 7px calc(6px + var(--tree-level, 0) * 22px);
|
||||
background: transparent;
|
||||
color: #4b5565;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.14s ease, color 0.14s ease, box-shadow 0.14s ease;
|
||||
}
|
||||
|
||||
.org-tree-node:hover,
|
||||
.org-tree-node.active {
|
||||
border-color: transparent;
|
||||
background: #eaf4ff;
|
||||
color: #1f6fbd;
|
||||
box-shadow: inset 3px 0 0 #409eff;
|
||||
}
|
||||
|
||||
.tree-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: #9098a8;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tree-toggle.hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.tree-node-icon {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 23px;
|
||||
height: 23px;
|
||||
border-radius: 5px;
|
||||
background: #f1f5f9;
|
||||
color: #607086;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.org-tree-node.active .tree-node-icon,
|
||||
.org-tree-node:hover .tree-node-icon {
|
||||
background: #409eff;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.tree-node-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tree-node-main strong,
|
||||
.tree-node-main small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tree-node-main strong {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tree-node-main small {
|
||||
color: #8b97a8;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.org-tree-children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.employee-search-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.employee-search-field span {
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.employee-search-field input {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(15, 23, 42, 0.1);
|
||||
border-radius: 16px;
|
||||
padding: 11px 14px;
|
||||
background: #ffffff;
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.employee-search-field input:focus {
|
||||
border-color: rgba(37, 99, 235, 0.62);
|
||||
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.ghost-button {
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
padding: 10px 16px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
background: #2563eb;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.primary-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.ghost-button.danger {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.empty-row {
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.org-employee-table {
|
||||
min-width: 0;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
:deep(.org-tree-branch) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
:deep(.org-tree-node) {
|
||||
display: grid;
|
||||
grid-template-columns: 18px 24px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
padding: 7px 8px 7px calc(6px + var(--tree-level, 0) * 22px);
|
||||
background: transparent;
|
||||
color: #4b5565;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.14s ease, color 0.14s ease, box-shadow 0.14s ease;
|
||||
}
|
||||
|
||||
:deep(.org-tree-node:hover),
|
||||
:deep(.org-tree-node.active) {
|
||||
border-color: transparent;
|
||||
background: #eaf4ff;
|
||||
color: #1f6fbd;
|
||||
box-shadow: inset 3px 0 0 #409eff;
|
||||
}
|
||||
|
||||
:deep(.tree-toggle) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: #9098a8;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
:deep(.tree-toggle.hidden) {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
:deep(.tree-node-icon) {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 23px;
|
||||
height: 23px;
|
||||
border-radius: 5px;
|
||||
background: #f1f5f9;
|
||||
color: #607086;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
:deep(.org-tree-node.active .tree-node-icon),
|
||||
:deep(.org-tree-node:hover .tree-node-icon) {
|
||||
background: #409eff;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
:deep(.tree-node-main) {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
:deep(.tree-node-main strong),
|
||||
:deep(.tree-node-main small) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:deep(.tree-node-main strong) {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
:deep(.tree-node-main small) {
|
||||
color: #8b97a8;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
:deep(.org-tree-children) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.org-permission-tree-card {
|
||||
grid-template-columns: minmax(300px, 0.32fr) minmax(680px, 1fr);
|
||||
}
|
||||
|
||||
.org-tree-panel {
|
||||
min-height: 320px;
|
||||
}
|
||||
|
||||
.panel-head,
|
||||
.employee-head {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="pagination-bar">
|
||||
<div class="pagination-summary">显示第 {{ startItem }} 条 - 第 {{ endItem }} 条,共 {{ total }} 条</div>
|
||||
<div class="pagination-actions">
|
||||
<span class="pagination-total-pages">总页数:{{ pageCount }}</span>
|
||||
<button class="pagination-arrow" type="button" :disabled="currentPage <= 1" aria-label="上一页" @click="goPage(currentPage - 1)">
|
||||
‹
|
||||
</button>
|
||||
<template v-for="item in visiblePages" :key="`${item.type}-${item.value}`">
|
||||
<span v-if="item.type === 'ellipsis'" class="pagination-ellipsis">...</span>
|
||||
<button
|
||||
v-else
|
||||
class="pagination-page"
|
||||
:class="{ 'pagination-page-active': item.value === currentPage }"
|
||||
type="button"
|
||||
@click="goPage(item.value)"
|
||||
>
|
||||
{{ item.value }}
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
class="pagination-arrow"
|
||||
type="button"
|
||||
:disabled="currentPage >= pageCount"
|
||||
aria-label="下一页"
|
||||
@click="goPage(currentPage + 1)"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
<label class="pagination-size-field">
|
||||
<span>每页条数:</span>
|
||||
<select :value="pageSize" @change="changePageSize">
|
||||
<option v-for="option in pageSizeOptions" :key="option" :value="option">{{ option }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
import { PAGE_SIZE_OPTIONS } from "../utils/pagination";
|
||||
|
||||
const props = defineProps({
|
||||
page: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
pageSize: {
|
||||
type: Number,
|
||||
default: 10
|
||||
},
|
||||
total: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
pageSizeOptions: {
|
||||
type: Array,
|
||||
default: () => PAGE_SIZE_OPTIONS
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:page", "update:pageSize"]);
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil(props.total / props.pageSize)));
|
||||
const currentPage = computed(() => Math.min(Math.max(1, props.page), pageCount.value));
|
||||
const startItem = computed(() => (props.total > 0 ? (currentPage.value - 1) * props.pageSize + 1 : 0));
|
||||
const endItem = computed(() => Math.min(props.total, currentPage.value * props.pageSize));
|
||||
const visiblePages = computed(() => {
|
||||
const count = pageCount.value;
|
||||
const page = currentPage.value;
|
||||
const pages = new Set([1, count, page, page - 1, page + 1]);
|
||||
|
||||
if (page <= 3) {
|
||||
pages.add(2);
|
||||
pages.add(3);
|
||||
pages.add(4);
|
||||
}
|
||||
|
||||
if (page >= count - 2) {
|
||||
pages.add(count - 1);
|
||||
pages.add(count - 2);
|
||||
pages.add(count - 3);
|
||||
}
|
||||
|
||||
const normalizedPages = Array.from(pages)
|
||||
.filter((item) => item >= 1 && item <= count)
|
||||
.sort((left, right) => left - right);
|
||||
|
||||
return normalizedPages.reduce((items, value, index) => {
|
||||
const previous = normalizedPages[index - 1];
|
||||
if (index > 0 && value - previous > 1) {
|
||||
items.push({ type: "ellipsis", value: `${previous}-${value}` });
|
||||
}
|
||||
items.push({ type: "page", value });
|
||||
return items;
|
||||
}, []);
|
||||
});
|
||||
|
||||
function changePageSize(event) {
|
||||
emit("update:pageSize", Number(event.target.value || props.pageSize));
|
||||
emit("update:page", 1);
|
||||
}
|
||||
|
||||
function goPage(nextPage) {
|
||||
const normalizedPage = Math.min(Math.max(1, nextPage), pageCount.value);
|
||||
if (normalizedPage !== props.page) {
|
||||
emit("update:page", normalizedPage);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<section class="page-grid">
|
||||
<section class="panel workspace-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<p class="eyebrow">流程工作台</p>
|
||||
<h3>{{ title }}</h3>
|
||||
</div>
|
||||
<span class="panel-tag">{{ panelTag }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="visibleSections.length" class="workspace-tabbar">
|
||||
<button
|
||||
v-for="section in visibleSections"
|
||||
:key="section.key"
|
||||
class="workspace-tab"
|
||||
:class="{ 'workspace-tab-active': section.key === activeSection?.key }"
|
||||
type="button"
|
||||
:title="section.desc"
|
||||
@click="selectSection(section.key)"
|
||||
>
|
||||
<span class="workspace-tab-step">{{ section.step }}</span>
|
||||
<div>
|
||||
<strong>{{ section.label }}</strong>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="activeSection" class="workspace-section-head">
|
||||
<div>
|
||||
<p class="eyebrow">当前环节</p>
|
||||
<span class="title-with-help" :title="activeSection.desc">
|
||||
<h3>{{ activeSection.label }}</h3>
|
||||
</span>
|
||||
</div>
|
||||
<span class="panel-tag">{{ activeSection.step }}</span>
|
||||
</div>
|
||||
|
||||
<KeepAlive v-if="activeSection">
|
||||
<component :is="activeSection.component" :key="activeSection.key" />
|
||||
</KeepAlive>
|
||||
|
||||
<div v-else class="feedback-banner error-banner">当前账号没有此流程工作台的访问权限。</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
import { readSession } from "../services/api";
|
||||
|
||||
const props = defineProps({
|
||||
eyebrow: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
panelTag: {
|
||||
type: String,
|
||||
default: "流程融合"
|
||||
},
|
||||
stageCode: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
sections: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const permissionCodes = computed(() => readSession()?.permission_codes || []);
|
||||
const visibleSections = computed(() =>
|
||||
props.sections.filter((section) => {
|
||||
if (section.permissionsAny?.length) {
|
||||
return section.permissionsAny.some((permission) => permissionCodes.value.includes(permission));
|
||||
}
|
||||
return permissionCodes.value.includes(section.permission);
|
||||
})
|
||||
);
|
||||
const activeSection = computed(() => {
|
||||
const sectionKey = String(route.query.tab || "");
|
||||
return visibleSections.value.find((section) => section.key === sectionKey) || visibleSections.value[0] || null;
|
||||
});
|
||||
|
||||
function selectSection(sectionKey) {
|
||||
router.replace({
|
||||
name: route.name,
|
||||
query: {
|
||||
...route.query,
|
||||
tab: sectionKey
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [route.query.tab, visibleSections.value.map((section) => section.key).join("|")],
|
||||
() => {
|
||||
if (!visibleSections.value.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTab = String(route.query.tab || "");
|
||||
if (!visibleSections.value.some((section) => section.key === currentTab)) {
|
||||
selectSection(visibleSections.value[0].key);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="smart-drawer-mask" @click.self="handleClose">
|
||||
<aside
|
||||
class="smart-drawer-shell"
|
||||
:class="drawerClass"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="labelledById"
|
||||
:aria-label="dialogLabel"
|
||||
:aria-describedby="description ? descriptionId : undefined"
|
||||
>
|
||||
<header class="smart-drawer-header">
|
||||
<slot name="header">
|
||||
<div class="smart-drawer-heading">
|
||||
<p v-if="eyebrow" class="smart-drawer-eyebrow">{{ eyebrow }}</p>
|
||||
<h2 v-if="title" :id="titleId" class="smart-drawer-title">{{ title }}</h2>
|
||||
<p v-if="description" :id="descriptionId" class="smart-drawer-description">{{ description }}</p>
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<div class="smart-drawer-actions">
|
||||
<slot name="head-actions" />
|
||||
<button class="smart-drawer-close" type="button" aria-label="关闭" title="关闭" @click="handleClose">
|
||||
<AppIcon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="smart-drawer-body">
|
||||
<slot v-if="$slots.default" />
|
||||
<slot v-else name="body" />
|
||||
</section>
|
||||
|
||||
<footer v-if="$slots.footer" class="smart-drawer-footer">
|
||||
<slot name="footer" />
|
||||
</footer>
|
||||
</aside>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, toRef, useId, useSlots } from "vue";
|
||||
|
||||
import { useBodyScrollLock } from "../composables/useBodyScrollLock";
|
||||
import AppIcon from "./AppIcon.vue";
|
||||
|
||||
const props = defineProps({
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
eyebrow: {
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: "lg",
|
||||
validator: (value) => ["lg", "xl", "fullscreen"].includes(value)
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
const slots = useSlots();
|
||||
const titleId = `smart-drawer-title-${useId()}`;
|
||||
const descriptionId = `smart-drawer-description-${useId()}`;
|
||||
|
||||
const drawerClass = computed(() => ({
|
||||
[`smart-drawer-${props.size}`]: true
|
||||
}));
|
||||
|
||||
const labelledById = computed(() => (props.title && !slots.header ? titleId : undefined));
|
||||
const dialogLabel = computed(() => (labelledById.value ? undefined : props.title || props.eyebrow || "业务抽屉"));
|
||||
|
||||
function handleClose() {
|
||||
emit("close");
|
||||
}
|
||||
|
||||
useBodyScrollLock(toRef(props, "open"));
|
||||
</script>
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<span :class="['status-badge', `status-badge-${tone}`]">
|
||||
<span class="status-badge-dot" aria-hidden="true"></span>
|
||||
<span>{{ label }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
import { getStatusLabel, getStatusTone } from "../utils/statusToneMap";
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: [String, Number, Boolean, null],
|
||||
default: ""
|
||||
},
|
||||
text: {
|
||||
type: String,
|
||||
default: ""
|
||||
}
|
||||
});
|
||||
|
||||
const label = computed(() => props.text || getStatusLabel(props.value));
|
||||
const tone = computed(() => getStatusTone(props.value || props.text) || "neutral");
|
||||
</script>
|
||||
@@ -0,0 +1,436 @@
|
||||
<template>
|
||||
<div ref="rootRef" class="stock-lot-select">
|
||||
<div class="stock-lot-control" :class="{ 'stock-lot-control-open': dropdownOpen }" @click="focusSearch">
|
||||
<span
|
||||
v-for="lot in selectedLots"
|
||||
:key="lot.lot_id"
|
||||
class="stock-lot-pill"
|
||||
:title="lotLabel(lot)"
|
||||
>
|
||||
<strong>{{ lot.lot_no }}</strong>
|
||||
<small>{{ formatWeight(displayWeight(lot)) }}</small>
|
||||
<button type="button" aria-label="移除库存批次号" @click.stop="removeLot(lot)">×</button>
|
||||
</span>
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
v-model.trim="searchText"
|
||||
class="stock-lot-search"
|
||||
type="text"
|
||||
:placeholder="selectedLots.length ? '' : placeholder"
|
||||
@focus="dropdownOpen = true"
|
||||
@keydown.down.prevent="moveActive(1)"
|
||||
@keydown.up.prevent="moveActive(-1)"
|
||||
@keydown.enter.prevent="chooseActive"
|
||||
@keydown.esc.prevent="dropdownOpen = false"
|
||||
@keydown.backspace="removeLastWhenEmpty"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="dropdownOpen" class="stock-lot-dropdown">
|
||||
<button
|
||||
v-for="(lot, index) in filteredOptions"
|
||||
:key="lot.lot_id"
|
||||
class="stock-lot-option"
|
||||
:class="{
|
||||
'stock-lot-option-active': index === activeIndex,
|
||||
'stock-lot-option-disabled': !canSelect(lot),
|
||||
'stock-lot-option-selected': isSelected(lot)
|
||||
}"
|
||||
type="button"
|
||||
:disabled="!canSelect(lot)"
|
||||
@mouseenter="activeIndex = index"
|
||||
@click="selectLot(lot)"
|
||||
>
|
||||
<span class="stock-lot-option-main">
|
||||
<strong>{{ lot.lot_no }}</strong>
|
||||
<small>{{ lot.item_code }} · {{ lot.item_name }}</small>
|
||||
<small v-if="lot.source_stock_lot_no">废料库存批次号:{{ lot.source_stock_lot_no }}</small>
|
||||
</span>
|
||||
<span class="stock-lot-option-meta">
|
||||
<span>{{ optionWeightLabel }} {{ formatWeight(displayWeight(lot)) }}</span>
|
||||
<span>¥{{ formatAmount(lot.unit_cost) }}/kg</span>
|
||||
<em v-if="lot.disabled_reason">{{ lot.disabled_reason }}</em>
|
||||
<em v-else-if="isSelected(lot)">已选择</em>
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="!filteredOptions.length" class="stock-lot-empty">没有匹配的库存批次号</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
|
||||
import { formatAmount, formatWeight } from "../utils/formatters";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: "搜索并选择库存批次号"
|
||||
},
|
||||
allowUnavailable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
maxSelections: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
displayWeightField: {
|
||||
type: String,
|
||||
default: "remaining_weight_kg"
|
||||
},
|
||||
optionWeightLabel: {
|
||||
type: String,
|
||||
default: "剩余"
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const rootRef = ref(null);
|
||||
const searchInputRef = ref(null);
|
||||
const searchText = ref("");
|
||||
const dropdownOpen = ref(false);
|
||||
const activeIndex = ref(0);
|
||||
|
||||
const selectedLots = computed(() => props.modelValue || []);
|
||||
const selectedLotIds = computed(() => new Set(selectedLots.value.map((lot) => Number(lot.lot_id))));
|
||||
const sortedOptions = computed(() =>
|
||||
[...(props.options || [])].sort((left, right) => {
|
||||
const leftUsable = left.is_available_for_issue ? 1 : 0;
|
||||
const rightUsable = right.is_available_for_issue ? 1 : 0;
|
||||
if (leftUsable !== rightUsable) {
|
||||
return rightUsable - leftUsable;
|
||||
}
|
||||
return String(left.lot_no || "").localeCompare(String(right.lot_no || ""), "zh-CN", { numeric: true });
|
||||
})
|
||||
);
|
||||
const filteredOptions = computed(() => {
|
||||
const keywords = normalizeText(searchText.value).split(/\s+/).filter(Boolean);
|
||||
if (!keywords.length) {
|
||||
return sortedOptions.value;
|
||||
}
|
||||
return sortedOptions.value.filter((lot) => {
|
||||
const body = normalizeText(`${lot.lot_no} ${lot.source_stock_lot_no} ${lot.source_material_sub_batch_no} ${lot.item_code} ${lot.item_name} ${lot.disabled_reason}`);
|
||||
return keywords.every((keyword) => body.includes(keyword));
|
||||
});
|
||||
});
|
||||
|
||||
watch([filteredOptions, selectedLotIds], ([rows]) => {
|
||||
resetActiveIndexToFirstSelectable(rows);
|
||||
});
|
||||
|
||||
watch(dropdownOpen, (isOpen) => {
|
||||
if (isOpen) {
|
||||
resetActiveIndexToFirstSelectable();
|
||||
}
|
||||
});
|
||||
|
||||
function resetActiveIndexToFirstSelectable(rows = filteredOptions.value) {
|
||||
if (!rows.length) {
|
||||
activeIndex.value = 0;
|
||||
return;
|
||||
}
|
||||
const firstSelectableIndex = rows.findIndex((lot) => canSelect(lot));
|
||||
activeIndex.value = firstSelectableIndex >= 0 ? firstSelectableIndex : 0;
|
||||
}
|
||||
|
||||
function syncActiveIndexToSelectable(rows = filteredOptions.value) {
|
||||
if (!rows.length) {
|
||||
activeIndex.value = 0;
|
||||
return;
|
||||
}
|
||||
const clampedIndex = Math.min(Math.max(activeIndex.value, 0), rows.length - 1);
|
||||
if (canSelect(rows[clampedIndex])) {
|
||||
activeIndex.value = clampedIndex;
|
||||
return;
|
||||
}
|
||||
const firstSelectableIndex = rows.findIndex((lot) => canSelect(lot));
|
||||
activeIndex.value = firstSelectableIndex >= 0 ? firstSelectableIndex : clampedIndex;
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function lotLabel(lot) {
|
||||
const stockLotSuffix = lot.source_stock_lot_no ? ` · 废料库存批次号:${lot.source_stock_lot_no}` : "";
|
||||
return `${lot.lot_no} · ${lot.item_code} · ${lot.item_name}${stockLotSuffix}`;
|
||||
}
|
||||
|
||||
function toFiniteNumber(value) {
|
||||
const numericValue = Number(value);
|
||||
return Number.isFinite(numericValue) ? numericValue : 0;
|
||||
}
|
||||
|
||||
function displayWeight(lot) {
|
||||
const configuredValue = lot?.[props.displayWeightField];
|
||||
if (configuredValue !== undefined && configuredValue !== null && configuredValue !== "") {
|
||||
return toFiniteNumber(configuredValue);
|
||||
}
|
||||
return toFiniteNumber(lot?.remaining_weight_kg);
|
||||
}
|
||||
|
||||
function normalizeSelectedIssuedWeight(lot) {
|
||||
return toFiniteNumber(lot?.issued_weight_kg ?? lot?.remaining_weight_kg ?? 0);
|
||||
}
|
||||
|
||||
function canSelect(lot) {
|
||||
if (!lot || (!props.allowUnavailable && !lot.is_available_for_issue) || isSelected(lot)) {
|
||||
return false;
|
||||
}
|
||||
return !props.maxSelections || selectedLots.value.length < props.maxSelections;
|
||||
}
|
||||
|
||||
function isSelected(lot) {
|
||||
return selectedLotIds.value.has(Number(lot?.lot_id));
|
||||
}
|
||||
|
||||
function focusSearch() {
|
||||
dropdownOpen.value = true;
|
||||
searchInputRef.value?.focus();
|
||||
}
|
||||
|
||||
function selectLot(lot) {
|
||||
if (!canSelect(lot)) {
|
||||
return;
|
||||
}
|
||||
const nextSelectedLots = props.maxSelections === 1 ? [] : selectedLots.value;
|
||||
emit("update:modelValue", [
|
||||
...nextSelectedLots,
|
||||
{
|
||||
...lot,
|
||||
issued_weight_kg: normalizeSelectedIssuedWeight(lot)
|
||||
}
|
||||
]);
|
||||
searchText.value = "";
|
||||
dropdownOpen.value = false;
|
||||
activeIndex.value = 0;
|
||||
}
|
||||
|
||||
function removeLot(lot) {
|
||||
emit("update:modelValue", selectedLots.value.filter((item) => Number(item.lot_id) !== Number(lot.lot_id)));
|
||||
}
|
||||
|
||||
function removeLastWhenEmpty() {
|
||||
if (searchText.value || !selectedLots.value.length) {
|
||||
return;
|
||||
}
|
||||
emit("update:modelValue", selectedLots.value.slice(0, -1));
|
||||
}
|
||||
|
||||
function moveActive(delta) {
|
||||
const rows = filteredOptions.value;
|
||||
if (!rows.length) {
|
||||
return;
|
||||
}
|
||||
let nextIndex = activeIndex.value;
|
||||
for (let step = 0; step < rows.length; step += 1) {
|
||||
nextIndex = (nextIndex + delta + rows.length) % rows.length;
|
||||
if (canSelect(rows[nextIndex])) {
|
||||
activeIndex.value = nextIndex;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function chooseActive() {
|
||||
const row = filteredOptions.value[activeIndex.value];
|
||||
if (row) {
|
||||
selectLot(row);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerDown(event) {
|
||||
if (!rootRef.value?.contains(event.target)) {
|
||||
dropdownOpen.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener("pointerdown", handlePointerDown, true);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stock-lot-select {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.stock-lot-control {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-height: 46px;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 1px 2px rgba(31, 45, 61, 0.04);
|
||||
cursor: text;
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.stock-lot-control-open {
|
||||
border-color: #409eff;
|
||||
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.12);
|
||||
}
|
||||
|
||||
.stock-lot-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 100%;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #d9ecff;
|
||||
border-radius: 4px;
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stock-lot-pill strong {
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stock-lot-pill small {
|
||||
color: #67a8e8;
|
||||
}
|
||||
|
||||
.stock-lot-pill button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #409eff;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.stock-lot-search {
|
||||
flex: 1 1 220px;
|
||||
min-width: 160px;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: #606266;
|
||||
font: inherit;
|
||||
padding: 6px 2px;
|
||||
}
|
||||
|
||||
.stock-lot-search::placeholder {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.stock-lot-dropdown {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 10px 28px rgba(31, 45, 61, 0.16);
|
||||
}
|
||||
|
||||
.stock-lot-option {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-height: 54px;
|
||||
padding: 10px 12px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
background: #ffffff;
|
||||
color: #606266;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.stock-lot-option:hover,
|
||||
.stock-lot-option-active {
|
||||
background: #f5f7fa;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.stock-lot-option-selected {
|
||||
background: #ecf5ff;
|
||||
}
|
||||
|
||||
.stock-lot-option-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.stock-lot-option-main,
|
||||
.stock-lot-option-meta {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stock-lot-option-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stock-lot-option-main strong,
|
||||
.stock-lot-option-main small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stock-lot-option-main small,
|
||||
.stock-lot-option-meta {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stock-lot-option-meta {
|
||||
justify-items: end;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stock-lot-option-meta em {
|
||||
color: #f56c6c;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.stock-lot-empty {
|
||||
color: #909399;
|
||||
padding: 18px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.stock-lot-option {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stock-lot-option-meta {
|
||||
justify-items: start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,550 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="open" class="workflow-drawer-mask stocktake-mask" @click.self="$emit('close')">
|
||||
<section class="stocktake-shell">
|
||||
<header class="stocktake-head">
|
||||
<div>
|
||||
<p class="eyebrow">嘉恒仓库 · 盘库</p>
|
||||
<h3>{{ activeStocktake ? activeStocktake.stocktake_no : "新建盘库" }}</h3>
|
||||
</div>
|
||||
<button class="ghost-button" type="button" @click="$emit('close')">关闭</button>
|
||||
</header>
|
||||
|
||||
<nav class="stocktake-steps stocktake-progress-rail" aria-label="盘库流程">
|
||||
<span
|
||||
v-for="(step, index) in flowSteps"
|
||||
:key="step.key"
|
||||
class="stocktake-step"
|
||||
:class="{
|
||||
active: activePanel === step.key,
|
||||
done: index < currentFlowIndex
|
||||
}"
|
||||
>
|
||||
<span>{{ step.no }}</span>
|
||||
<strong>{{ step.label }}</strong>
|
||||
</span>
|
||||
</nav>
|
||||
|
||||
<div class="stocktake-notice-row" :class="{ empty: !dialogMessage && !dialogError }" aria-live="polite">
|
||||
<div v-if="dialogMessage" class="feedback-banner">{{ dialogMessage }}</div>
|
||||
<div v-if="dialogError" class="feedback-banner error-banner">{{ dialogError }}</div>
|
||||
</div>
|
||||
|
||||
<div class="stocktake-main">
|
||||
<div class="stocktake-flow-column">
|
||||
<section v-if="activePanel === 'export'" class="stocktake-panel stocktake-single-panel">
|
||||
<div class="stocktake-action-card stocktake-action-card-wide">
|
||||
<span class="stocktake-stage-tag">当前仓库:{{ currentWarehouseName }}</span>
|
||||
<strong>01 确认盘库并导出仓库清单</strong>
|
||||
<p>点击后系统会锁定当前仓库,并导出本次盘库 Excel 清单。盘库结束前该仓库不能出入库。</p>
|
||||
<div class="stocktake-inline-actions">
|
||||
<button class="primary-button" type="button" :disabled="busy || !currentWarehouseId" @click="startAndExportStocktake">
|
||||
{{ busy ? "处理中..." : "确认盘库并导出仓库清单" }}
|
||||
</button>
|
||||
<button v-if="activeStocktake?.status === 'LOCKED'" class="ghost-button" type="button" :disabled="busy" @click="exportStocktake">
|
||||
重新导出清单
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="activePanel === 'import'" class="stocktake-panel stocktake-panel-import">
|
||||
<div class="stocktake-action-card stocktake-action-card-wide">
|
||||
<span class="stocktake-stage-tag">当前盘库单:{{ activeStocktake?.stocktake_no || "-" }}</span>
|
||||
<strong>02 导入盘点后的 Excel</strong>
|
||||
<p>只允许导入当前仓库、本次盘库单导出的清单。导入成功后会立刻弹出差异对比。</p>
|
||||
<label class="import-dropzone">
|
||||
<input ref="fileInputRef" class="hidden-file-input" type="file" accept=".xlsx,.xlsm" :disabled="busy" @change="importStocktake" />
|
||||
<strong>{{ busy ? "导入中..." : "选择盘点后的 Excel" }}</strong>
|
||||
<span>如果差异确认时取消,可以修改 Excel 后重新导入覆盖旧差异。</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="stocktake-panel stocktake-panel-history">
|
||||
<div class="table-wrap">
|
||||
<table class="data-table compact-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>盘库单号</th>
|
||||
<th>仓库</th>
|
||||
<th>状态</th>
|
||||
<th>开始时间</th>
|
||||
<th>确认时间</th>
|
||||
<th>盘盈</th>
|
||||
<th>盘亏</th>
|
||||
<th>新增批次</th>
|
||||
<th>缺失批次</th>
|
||||
<th>影响金额</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in paginatedHistoryRows" :key="row.id">
|
||||
<td>{{ row.stocktake_no }}</td>
|
||||
<td>{{ stocktakeWarehouseNames(row) }}</td>
|
||||
<td>{{ formatStocktakeStatusLabel(row.status) }}</td>
|
||||
<td>{{ formatDateTime(row.started_at) }}</td>
|
||||
<td>{{ formatDateTime(row.confirmed_at) }}</td>
|
||||
<td>{{ stocktakeSummaryValue(row, "gain_count") }}</td>
|
||||
<td>{{ stocktakeSummaryValue(row, "loss_count") }}</td>
|
||||
<td>{{ stocktakeSummaryValue(row, "new_lot_count") }}</td>
|
||||
<td>{{ stocktakeSummaryValue(row, "missing_count") }}</td>
|
||||
<td>¥{{ formatAmount(stocktakeSummaryValue(row, "diff_amount")) }}</td>
|
||||
</tr>
|
||||
<tr v-if="!currentWarehouseHistoryRows.length">
|
||||
<td colspan="10" class="empty-row">暂无盘库记录</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<PaginationBar
|
||||
v-model:page="historyPage"
|
||||
v-model:page-size="historyPageSize"
|
||||
:total="currentWarehouseHistoryRows.length"
|
||||
:page-size-options="[6]"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-if="diffDialogOpen" class="stocktake-diff-modal-mask" @click.self="closeDiffDialog">
|
||||
<section class="stocktake-diff-modal">
|
||||
<header class="stocktake-diff-modal-head">
|
||||
<div>
|
||||
<p class="eyebrow">盘库差异对比</p>
|
||||
<h3>{{ activeStocktake?.stocktake_no }} · {{ currentWarehouseName }}</h3>
|
||||
</div>
|
||||
<button class="ghost-button" type="button" :disabled="busy" @click="closeDiffDialog">取消,重新导入</button>
|
||||
</header>
|
||||
<div class="stocktake-summary-grid">
|
||||
<article v-for="card in summaryCards" :key="card.key" class="stocktake-summary-card" :class="card.tone">
|
||||
<span>{{ card.label }}</span>
|
||||
<strong>{{ card.value }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
<div v-if="isCleanEmptyStocktake" class="stocktake-empty-state">
|
||||
<strong>空库盘库清单已确认到差异页</strong>
|
||||
<span>系统库存快照和盘点清单都没有明细。确认盘库后只会解锁仓库,不会生成库存调整流水。</span>
|
||||
</div>
|
||||
<div class="stocktake-diff-toolbar">
|
||||
<button class="ghost-button" type="button" :class="{ active: diffFilter === 'ALL' }" @click="diffFilter = 'ALL'">全部</button>
|
||||
<button class="ghost-button" type="button" :class="{ active: diffFilter === 'DIFF' }" @click="diffFilter = 'DIFF'">只看差异</button>
|
||||
<button class="ghost-button" type="button" :class="{ active: diffFilter === 'ERROR' }" @click="diffFilter = 'ERROR'">只看异常</button>
|
||||
</div>
|
||||
<div class="stocktake-diff-table stocktake-diff-table-v2">
|
||||
<div class="stocktake-diff-header">系统快照</div>
|
||||
<div class="stocktake-diff-change-header">变化</div>
|
||||
<div class="stocktake-diff-header">实盘结果</div>
|
||||
|
||||
<template v-for="line in filteredLines" :key="line.id">
|
||||
<div class="stocktake-diff-cell stocktake-diff-cell-left" :class="diffClass(line)">
|
||||
<strong>{{ line.item_code }} · {{ line.item_name }}</strong>
|
||||
<span>批次:{{ line.lot_no || "-" }}</span>
|
||||
<span>来源库存批次号:{{ line.source_material_sub_batch_no || "-" }}</span>
|
||||
<span>重量:{{ formatWeight(line.snapshot_weight_kg) }}</span>
|
||||
<span v-if="!isWeightOnlyStocktakeLine(line)">数量:{{ formatQty(line.snapshot_qty) }}</span>
|
||||
<span>单价:¥{{ formatAmount(line.snapshot_unit_cost) }}</span>
|
||||
</div>
|
||||
<div class="stocktake-diff-change" :class="diffClass(line)">
|
||||
<strong>{{ formatStocktakeDiffTypeLabel(line.diff_type) }}</strong>
|
||||
<span>行状态:{{ formatStocktakeRowStatusLabel(line.row_status) }}</span>
|
||||
<span>重量差:{{ formatWeight(line.diff_weight_kg) }}</span>
|
||||
<span v-if="!isWeightOnlyStocktakeLine(line)">数量差:{{ formatQty(line.diff_qty) }}</span>
|
||||
<span>金额影响:¥{{ formatAmount(line.diff_amount) }}</span>
|
||||
<em v-if="line.error_message">{{ line.error_message }}</em>
|
||||
</div>
|
||||
<div class="stocktake-diff-cell stocktake-diff-cell-right" :class="diffClass(line)">
|
||||
<strong>{{ line.item_code }} · {{ line.item_name }}</strong>
|
||||
<span>批次:{{ line.lot_no || "-" }}</span>
|
||||
<span>盘点重量:{{ formatWeight(line.counted_weight_kg) }}</span>
|
||||
<span v-if="!isWeightOnlyStocktakeLine(line)">盘点数量:{{ formatQty(line.counted_qty) }}</span>
|
||||
<span>备注:{{ line.remark || "-" }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="!filteredLines.length" class="stocktake-diff-empty-v2">
|
||||
<strong>没有需要展示的差异行</strong>
|
||||
<span v-if="isCleanEmptyStocktake">这是空库盘库,确认后只解锁仓库,不生成库存调整流水。</span>
|
||||
<span v-else>当前筛选条件下没有明细。</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stocktake-footer">
|
||||
<button class="primary-button" type="button" :disabled="busy || !canConfirmCurrentStocktake" @click="openConfirmDialog">
|
||||
{{ busy ? "确认中..." : "确认更新库存并解锁" }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="confirmDialogOpen" class="stocktake-confirm-mask" @click.self="closeConfirmDialog">
|
||||
<section class="stocktake-confirm-dialog">
|
||||
<strong>是否确认盘库?</strong>
|
||||
<span>确认后系统会按当前差异重置库存,并解锁本次盘库仓库。该操作会写入盘库记录。</span>
|
||||
<div class="stocktake-confirm-actions">
|
||||
<button class="ghost-button" type="button" :disabled="busy" @click="closeConfirmDialog">取消</button>
|
||||
<button class="primary-button" type="button" :disabled="busy" @click="confirmStocktake">
|
||||
{{ busy ? "确认中..." : "确认盘库" }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
import PaginationBar from "./PaginationBar.vue";
|
||||
import { downloadResource, fetchResource, postResource, uploadResource } from "../services/api";
|
||||
import { formatStocktakeDiffTypeLabel, formatStocktakeRowStatusLabel, formatStocktakeStatusLabel } from "../utils/dictionaries";
|
||||
import { formatAmount, formatDateTime, formatQty, formatWeight } from "../utils/formatters";
|
||||
import { usePagination } from "../utils/pagination";
|
||||
import {
|
||||
diffTone,
|
||||
filterStocktakesForWarehouse,
|
||||
findActiveStocktakeForWarehouse,
|
||||
hasBlockingDiffErrors,
|
||||
isEmptyStocktakeRead,
|
||||
nextStocktakePanel,
|
||||
splitDiffLines
|
||||
} from "../utils/stocktakeWorkflow";
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
warehouses: { type: Array, default: () => [] },
|
||||
defaultWarehouseId: { type: Number, default: 0 }
|
||||
});
|
||||
const emit = defineEmits(["close", "changed", "message", "error"]);
|
||||
|
||||
const flowSteps = [
|
||||
{ key: "export", no: "01", label: "确认盘库并导出" },
|
||||
{ key: "import", no: "02", label: "导入清单" }
|
||||
];
|
||||
|
||||
const activePanel = ref("export");
|
||||
const activeStocktake = ref(null);
|
||||
const previewLines = ref([]);
|
||||
const previewSummary = ref({});
|
||||
const busy = ref(false);
|
||||
const fileInputRef = ref(null);
|
||||
const diffFilter = ref("ALL");
|
||||
const confirmDialogOpen = ref(false);
|
||||
const diffDialogOpen = ref(false);
|
||||
const historyRows = ref([]);
|
||||
const currentFlowIndex = computed(() => Math.max(0, flowSteps.findIndex((step) => step.key === activePanel.value)));
|
||||
|
||||
const currentWarehouseId = computed(() => Number(props.defaultWarehouseId || 0));
|
||||
const currentWarehouse = computed(
|
||||
() => props.warehouses.find((warehouse) => Number(warehouse.id) === currentWarehouseId.value) || null
|
||||
);
|
||||
const currentWarehouseName = computed(() => currentWarehouse.value?.warehouse_name || "未选择仓库");
|
||||
const currentWarehouseHistoryRows = computed(() => filterStocktakesForWarehouse(historyRows.value, currentWarehouseId.value));
|
||||
const {
|
||||
page: historyPage,
|
||||
pageSize: historyPageSize,
|
||||
rows: paginatedHistoryRows
|
||||
} = usePagination(currentWarehouseHistoryRows, 6);
|
||||
|
||||
const canConfirmCurrentStocktake = computed(
|
||||
() => Boolean(activeStocktake.value?.id) && !hasBlockingDiffErrors(previewLines.value)
|
||||
);
|
||||
|
||||
const dialogMessage = ref("");
|
||||
const dialogError = ref("");
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
async (open) => {
|
||||
if (!open) return;
|
||||
activePanel.value = "export";
|
||||
activeStocktake.value = null;
|
||||
previewLines.value = [];
|
||||
previewSummary.value = {};
|
||||
diffFilter.value = "ALL";
|
||||
confirmDialogOpen.value = false;
|
||||
diffDialogOpen.value = false;
|
||||
dialogMessage.value = "";
|
||||
dialogError.value = "";
|
||||
if (!currentWarehouseId.value) {
|
||||
setDialogError("请先在仓库栏选中一个仓库,再进行盘库");
|
||||
return;
|
||||
}
|
||||
await loadHistory();
|
||||
const activeRow = findActiveStocktakeForWarehouse(historyRows.value, currentWarehouseId.value);
|
||||
if (activeRow) {
|
||||
await continueStocktake(activeRow, { silent: true });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const summaryCards = computed(() => [
|
||||
{ key: "match", label: "一致", value: previewSummary.value.match_count || 0, tone: "neutral" },
|
||||
{ key: "gain", label: "盘盈", value: previewSummary.value.gain_count || 0, tone: "gain" },
|
||||
{ key: "loss", label: "盘亏", value: previewSummary.value.loss_count || 0, tone: "loss" },
|
||||
{ key: "new", label: "新增批次", value: previewSummary.value.new_lot_count || 0, tone: "new" },
|
||||
{ key: "missing", label: "缺失批次", value: previewSummary.value.missing_count || 0, tone: "missing" },
|
||||
{ key: "error", label: "异常", value: previewSummary.value.error_count || 0, tone: "error" },
|
||||
{ key: "amount", label: "影响金额", value: `¥${formatAmount(previewSummary.value.diff_amount || 0)}`, tone: "amount" }
|
||||
]);
|
||||
|
||||
const filteredLines = computed(() => splitDiffLines(previewLines.value, diffFilter.value));
|
||||
|
||||
const isCleanEmptyStocktake = computed(
|
||||
() =>
|
||||
diffDialogOpen.value &&
|
||||
["EXPORTED", "IMPORTED"].includes(String(activeStocktake.value?.status || "").toUpperCase()) &&
|
||||
previewLines.value.length === 0
|
||||
);
|
||||
|
||||
function isWeightOnlyStocktakeLine(line) {
|
||||
const warehouseType = String(line?.warehouse_type || currentWarehouse.value?.warehouse_type || "").toUpperCase();
|
||||
return ["RAW", "AUX", "SCRAP"].includes(warehouseType) || String(line?.item_type || "").toUpperCase() === "RAW_MATERIAL";
|
||||
}
|
||||
|
||||
function diffClass(line) {
|
||||
return `diff-${diffTone(line)}`;
|
||||
}
|
||||
|
||||
function stocktakeWarehouseNames(row) {
|
||||
return Array.isArray(row?.warehouses) ? row.warehouses.map((item) => item.warehouse_name).filter(Boolean).join("、") : "-";
|
||||
}
|
||||
|
||||
function stocktakeSummaryValue(row, key) {
|
||||
return Number(row?.summary?.[key] || 0);
|
||||
}
|
||||
|
||||
async function startAndExportStocktake() {
|
||||
clearDialogFeedback();
|
||||
if (!currentWarehouseId.value) {
|
||||
setDialogError("请先在仓库栏选中一个仓库");
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
try {
|
||||
const stocktake = await postResource("/inventory/stocktakes", {
|
||||
warehouse_ids: [currentWarehouseId.value],
|
||||
remark: `${currentWarehouseName.value}盘库`
|
||||
});
|
||||
activeStocktake.value = stocktake;
|
||||
await downloadResource(`/inventory/stocktakes/${stocktake.id}/export`, `${stocktake.stocktake_no}_${currentWarehouseName.value}_盘库清单.xlsx`);
|
||||
const exportedStocktake = await fetchResource(`/inventory/stocktakes/${stocktake.id}`, null);
|
||||
if (!exportedStocktake) {
|
||||
throw new Error("导出后读取盘库单失败,请重新打开盘库");
|
||||
}
|
||||
await loadHistory();
|
||||
await applyLoadedStocktake(exportedStocktake, { silent: true });
|
||||
setDialogMessage(`盘库单 ${exportedStocktake.stocktake_no} 已导出,${currentWarehouseName.value} 已锁库`);
|
||||
emit("changed");
|
||||
} catch (error) {
|
||||
setDialogError(error.message || "开始盘库并导出失败");
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
try {
|
||||
if (!currentWarehouseId.value) {
|
||||
historyRows.value = [];
|
||||
return;
|
||||
}
|
||||
historyRows.value = await fetchResource(`/inventory/stocktakes?warehouse_id=${currentWarehouseId.value}&limit=100`, []);
|
||||
} catch (error) {
|
||||
setDialogError(error.message || "读取盘库记录失败");
|
||||
}
|
||||
}
|
||||
|
||||
function clearDialogFeedback() {
|
||||
dialogMessage.value = "";
|
||||
dialogError.value = "";
|
||||
}
|
||||
|
||||
function setDialogMessage(message) {
|
||||
dialogMessage.value = message;
|
||||
dialogError.value = "";
|
||||
emit("message", message);
|
||||
}
|
||||
|
||||
function setDialogError(message) {
|
||||
dialogError.value = message;
|
||||
dialogMessage.value = "";
|
||||
emit("error", message);
|
||||
}
|
||||
|
||||
async function applyLoadedStocktake(stocktake, options = {}) {
|
||||
const nextPanel = nextStocktakePanel(stocktake);
|
||||
let loadedLines = [];
|
||||
confirmDialogOpen.value = false;
|
||||
diffDialogOpen.value = false;
|
||||
previewLines.value = [];
|
||||
activeStocktake.value = null;
|
||||
previewSummary.value = {};
|
||||
|
||||
if (nextPanel === "diff" && String(stocktake?.status || "").toUpperCase() === "IMPORTED") {
|
||||
const lineRows = await fetchResource(`/inventory/stocktakes/${stocktake.id}/lines?warehouse_id=${currentWarehouseId.value}`, null);
|
||||
if (!Array.isArray(lineRows)) {
|
||||
throw new Error("读取盘库明细失败,请重新打开盘库单");
|
||||
}
|
||||
loadedLines = lineRows;
|
||||
}
|
||||
|
||||
activeStocktake.value = stocktake;
|
||||
previewSummary.value = stocktake?.summary || {};
|
||||
previewLines.value = loadedLines;
|
||||
activePanel.value = nextPanel === "diff" || nextPanel === "history" ? "import" : nextPanel;
|
||||
diffDialogOpen.value = nextPanel === "diff";
|
||||
|
||||
if (options.silent) return;
|
||||
if (nextPanel === "export") {
|
||||
setDialogMessage(`${stocktake.stocktake_no} 已锁库,请导出盘库清单`);
|
||||
} else if (nextPanel === "import") {
|
||||
setDialogMessage(`${stocktake.stocktake_no} 已导出,请导入盘点清单`);
|
||||
} else if (nextPanel === "diff" && isEmptyStocktakeRead(stocktake)) {
|
||||
setDialogMessage(`${stocktake.stocktake_no} 是空库盘库单,可确认后解锁`);
|
||||
} else if (nextPanel === "diff") {
|
||||
setDialogMessage(`${stocktake.stocktake_no} 已导入,请核对差异后确认`);
|
||||
}
|
||||
}
|
||||
|
||||
async function continueStocktake(row, options = {}) {
|
||||
if (!row?.id) return;
|
||||
clearDialogFeedback();
|
||||
busy.value = true;
|
||||
try {
|
||||
const stocktake = await fetchResource(`/inventory/stocktakes/${row.id}`, row);
|
||||
await applyLoadedStocktake(stocktake, options);
|
||||
} catch (error) {
|
||||
setDialogError(error.message || "继续处理盘库单失败");
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function exportStocktake() {
|
||||
if (!activeStocktake.value?.id) return;
|
||||
clearDialogFeedback();
|
||||
busy.value = true;
|
||||
try {
|
||||
await downloadResource(`/inventory/stocktakes/${activeStocktake.value.id}/export`, `${activeStocktake.value.stocktake_no}_${currentWarehouseName.value}_盘库清单.xlsx`);
|
||||
const exportedStocktake = await fetchResource(`/inventory/stocktakes/${activeStocktake.value.id}`, null);
|
||||
if (!exportedStocktake) {
|
||||
throw new Error("导出后读取盘库单失败,请刷新后继续处理");
|
||||
}
|
||||
await applyLoadedStocktake(exportedStocktake, { silent: true });
|
||||
if (isEmptyStocktakeRead(exportedStocktake)) {
|
||||
setDialogMessage(`空盘库单 ${exportedStocktake.stocktake_no} 已导出,可直接确认盘库后解锁仓库`);
|
||||
} else {
|
||||
setDialogMessage(`盘库清单 ${exportedStocktake.stocktake_no} 已导出,请清点后导入`);
|
||||
}
|
||||
} catch (error) {
|
||||
setDialogError(error.message || "导出盘库清单失败");
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function importStocktake(event) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) {
|
||||
setDialogError("请选择盘点后的 Excel 清单");
|
||||
return;
|
||||
}
|
||||
if (!activeStocktake.value?.id) {
|
||||
const fallbackRow = findActiveStocktakeForWarehouse(historyRows.value, currentWarehouseId.value);
|
||||
if (fallbackRow) {
|
||||
await continueStocktake(fallbackRow, { silent: true });
|
||||
}
|
||||
if (!activeStocktake.value?.id) {
|
||||
setDialogError("没有可导入的盘库单,请先创建盘库单或从盘库记录继续处理");
|
||||
if (fileInputRef.value) fileInputRef.value.value = "";
|
||||
return;
|
||||
}
|
||||
}
|
||||
clearDialogFeedback();
|
||||
busy.value = true;
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const result = await uploadResource(`/inventory/stocktakes/${activeStocktake.value.id}/import`, formData);
|
||||
if (!result?.stocktake) {
|
||||
throw new Error("导入接口返回异常,请重新导入盘点清单");
|
||||
}
|
||||
activeStocktake.value = result.stocktake;
|
||||
previewLines.value = Array.isArray(result.lines) ? result.lines : [];
|
||||
previewSummary.value = result.summary || {};
|
||||
confirmDialogOpen.value = false;
|
||||
diffDialogOpen.value = true;
|
||||
activePanel.value = "import";
|
||||
await loadHistory();
|
||||
if (!previewLines.value.length) {
|
||||
setDialogMessage(`空盘库单 ${result.stocktake.stocktake_no} 已导入:无库存差异,确认盘库后解锁仓库`);
|
||||
} else if (hasBlockingDiffErrors(previewLines.value)) {
|
||||
setDialogError(`盘点清单已导入,但存在 ${previewSummary.value.error_count || 0} 行异常,请处理后再确认`);
|
||||
} else {
|
||||
setDialogMessage(`盘点清单已导入:发现 ${previewSummary.value.gain_count || 0} 行盘盈、${previewSummary.value.loss_count || 0} 行盘亏、${previewSummary.value.new_lot_count || 0} 行新增批次、${previewSummary.value.missing_count || 0} 行缺失批次`);
|
||||
}
|
||||
} catch (error) {
|
||||
setDialogError(error.message || "导入盘点清单失败");
|
||||
} finally {
|
||||
busy.value = false;
|
||||
if (fileInputRef.value) fileInputRef.value.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function openConfirmDialog() {
|
||||
clearDialogFeedback();
|
||||
if (!activeStocktake.value?.id) {
|
||||
setDialogError("没有可确认的盘库单,请先创建或继续处理盘库单");
|
||||
return;
|
||||
}
|
||||
if (!canConfirmCurrentStocktake.value) {
|
||||
setDialogError("存在异常行或没有可确认的盘库单,不能确认盘库");
|
||||
return;
|
||||
}
|
||||
confirmDialogOpen.value = true;
|
||||
}
|
||||
|
||||
function closeConfirmDialog() {
|
||||
if (busy.value) return;
|
||||
confirmDialogOpen.value = false;
|
||||
}
|
||||
|
||||
function closeDiffDialog() {
|
||||
if (busy.value) return;
|
||||
diffDialogOpen.value = false;
|
||||
}
|
||||
|
||||
async function confirmStocktake() {
|
||||
clearDialogFeedback();
|
||||
if (!activeStocktake.value?.id) {
|
||||
setDialogError("没有可确认的盘库单,请先创建或继续处理盘库单");
|
||||
return;
|
||||
}
|
||||
if (!canConfirmCurrentStocktake.value) {
|
||||
setDialogError("存在异常行或没有可确认的盘库单,不能确认盘库");
|
||||
return;
|
||||
}
|
||||
busy.value = true;
|
||||
try {
|
||||
const confirmedStocktake = await postResource(`/inventory/stocktakes/${activeStocktake.value.id}/confirm`, {
|
||||
remark: "盘库确认"
|
||||
});
|
||||
activeStocktake.value = confirmedStocktake;
|
||||
await loadHistory();
|
||||
confirmDialogOpen.value = false;
|
||||
diffDialogOpen.value = false;
|
||||
activePanel.value = "export";
|
||||
activeStocktake.value = null;
|
||||
previewLines.value = [];
|
||||
previewSummary.value = {};
|
||||
setDialogMessage(`盘库单 ${confirmedStocktake.stocktake_no} 已确认并解锁`);
|
||||
emit("changed");
|
||||
} catch (error) {
|
||||
setDialogError(error.message || "确认盘库失败");
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,580 @@
|
||||
<template>
|
||||
<div ref="controlRef" class="table-control-bar">
|
||||
<label class="table-search-field">
|
||||
<span>{{ searchLabel }}</span>
|
||||
<input :value="search" type="search" :placeholder="placeholder" @input="emit('update:search', $event.target.value)" />
|
||||
</label>
|
||||
<div class="table-view-mode-switch" role="group" aria-label="表格展示模式">
|
||||
<button
|
||||
type="button"
|
||||
class="table-view-mode-button"
|
||||
:class="{ 'table-view-mode-active': tableMode === 'overview' }"
|
||||
:aria-pressed="tableMode === 'overview'"
|
||||
@click="setTableMode('overview')"
|
||||
>
|
||||
概览模式
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="table-view-mode-button"
|
||||
:class="{ 'table-view-mode-active': tableMode === 'wide' }"
|
||||
:aria-pressed="tableMode === 'wide'"
|
||||
@click="setTableMode('wide')"
|
||||
>
|
||||
宽表模式
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
|
||||
import { normalizeTableViewMode, readStoredTableMode, TABLE_VIEW_MODE_STORAGE_KEY } from "../utils/tableViewMode";
|
||||
|
||||
const props = defineProps({
|
||||
search: {
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
searchLabel: {
|
||||
type: String,
|
||||
default: "搜索"
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: "输入编码、名称、客户、单号等关键词"
|
||||
},
|
||||
sortKey: {
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
sortDirection: {
|
||||
type: String,
|
||||
default: "desc"
|
||||
},
|
||||
sortOptions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:search", "update:sortKey", "update:sortDirection"]);
|
||||
|
||||
function normalizeTableMode(value) {
|
||||
return normalizeTableViewMode(value);
|
||||
}
|
||||
|
||||
const controlRef = ref(null);
|
||||
const tableMode = ref(readStoredTableMode());
|
||||
let enhancedTable = null;
|
||||
let modeTable = null;
|
||||
let modeTableObserver = null;
|
||||
let tooltipTable = null;
|
||||
let tooltipElement = null;
|
||||
let tooltipCleanupHandlers = [];
|
||||
let cleanupHandlers = [];
|
||||
let headerObserver = null;
|
||||
let enhanceFrame = 0;
|
||||
let modeFrame = 0;
|
||||
let isEnhancing = false;
|
||||
|
||||
function normalizeLabel(value) {
|
||||
return String(value || "")
|
||||
.replace(/\s+/g, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function findTableAfterControl() {
|
||||
const control = controlRef.value;
|
||||
if (!control) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let sibling = control.nextElementSibling;
|
||||
while (sibling) {
|
||||
if (sibling.matches?.("table")) {
|
||||
return sibling;
|
||||
}
|
||||
|
||||
const nestedTable = sibling.querySelector?.("table");
|
||||
if (nestedTable) {
|
||||
return nestedTable;
|
||||
}
|
||||
|
||||
sibling = sibling.nextElementSibling;
|
||||
}
|
||||
|
||||
const scope =
|
||||
control.closest(".panel, .workflow-drawer, .workflow-section-card, .workflow-summary-card, .compact-side-drawer") ||
|
||||
control.parentElement;
|
||||
const tables = Array.from(scope?.querySelectorAll("table") || []);
|
||||
return tables.find((table) => control.compareDocumentPosition(table) & Node.DOCUMENT_POSITION_FOLLOWING) || null;
|
||||
}
|
||||
|
||||
function getTableViewWrap(table) {
|
||||
return (
|
||||
table?.closest?.(".table-wrap, .drawer-data-table-wrap, .special-adjustment-table-wrap") ||
|
||||
table?.parentElement ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function clearTableViewClasses(table) {
|
||||
if (!table) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wrap = getTableViewWrap(table);
|
||||
table.classList.remove("data-table-overview", "data-table-wide");
|
||||
wrap?.classList.remove("table-view-overview", "table-view-wide");
|
||||
delete table.dataset.tableViewMode;
|
||||
}
|
||||
|
||||
function disconnectModeObserver() {
|
||||
if (modeTableObserver) {
|
||||
modeTableObserver.disconnect();
|
||||
modeTableObserver = null;
|
||||
}
|
||||
}
|
||||
|
||||
function hideCellTooltip() {
|
||||
tooltipElement?.classList.remove("table-cell-hover-tooltip-visible");
|
||||
}
|
||||
|
||||
function ensureTooltipElement() {
|
||||
if (typeof document === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
tooltipElement = document.querySelector(".table-cell-hover-tooltip");
|
||||
if (!tooltipElement) {
|
||||
tooltipElement = document.createElement("div");
|
||||
tooltipElement.className = "table-cell-hover-tooltip";
|
||||
tooltipElement.setAttribute("role", "tooltip");
|
||||
document.body.appendChild(tooltipElement);
|
||||
}
|
||||
|
||||
return tooltipElement;
|
||||
}
|
||||
|
||||
function positionCellTooltip(event) {
|
||||
if (!tooltipElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const padding = 14;
|
||||
const offset = 16;
|
||||
const rect = tooltipElement.getBoundingClientRect();
|
||||
const nextLeft = Math.min(event.clientX + offset, window.innerWidth - rect.width - padding);
|
||||
const nextTop = Math.min(event.clientY + offset, window.innerHeight - rect.height - padding);
|
||||
|
||||
tooltipElement.style.left = `${Math.max(padding, nextLeft)}px`;
|
||||
tooltipElement.style.top = `${Math.max(padding, nextTop)}px`;
|
||||
}
|
||||
|
||||
function getTooltipCell(event) {
|
||||
const cell = event.target?.closest?.('td[data-table-tooltip="auto"]');
|
||||
if (!cell || !tooltipTable?.contains(cell)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return cell;
|
||||
}
|
||||
|
||||
function showCellTooltip(cell, event) {
|
||||
const value = cell.dataset.tableFullValue || "";
|
||||
if (!value || !["overview", "wide"].includes(tableMode.value)) {
|
||||
hideCellTooltip();
|
||||
return;
|
||||
}
|
||||
|
||||
const tooltip = ensureTooltipElement();
|
||||
if (!tooltip) {
|
||||
return;
|
||||
}
|
||||
|
||||
tooltip.textContent = value;
|
||||
tooltip.classList.add("table-cell-hover-tooltip-visible");
|
||||
positionCellTooltip(event);
|
||||
}
|
||||
|
||||
function clearTooltipListeners() {
|
||||
tooltipCleanupHandlers.forEach((cleanup) => cleanup());
|
||||
tooltipCleanupHandlers = [];
|
||||
tooltipTable = null;
|
||||
hideCellTooltip();
|
||||
}
|
||||
|
||||
function bindCellTooltip(table) {
|
||||
if (!table || tooltipTable === table) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTooltipListeners();
|
||||
tooltipTable = table;
|
||||
|
||||
const handleMouseOver = (event) => {
|
||||
const cell = getTooltipCell(event);
|
||||
if (cell) {
|
||||
showCellTooltip(cell, event);
|
||||
}
|
||||
};
|
||||
const handleMouseMove = (event) => {
|
||||
const cell = getTooltipCell(event);
|
||||
if (cell) {
|
||||
showCellTooltip(cell, event);
|
||||
}
|
||||
};
|
||||
const handleMouseOut = (event) => {
|
||||
const cell = getTooltipCell(event);
|
||||
if (cell && !cell.contains(event.relatedTarget)) {
|
||||
hideCellTooltip();
|
||||
}
|
||||
};
|
||||
const handleDocumentMouseMove = (event) => {
|
||||
if (!getTooltipCell(event)) {
|
||||
hideCellTooltip();
|
||||
}
|
||||
};
|
||||
|
||||
table.addEventListener("mouseover", handleMouseOver);
|
||||
table.addEventListener("mousemove", handleMouseMove);
|
||||
table.addEventListener("mouseout", handleMouseOut);
|
||||
document.addEventListener("mousemove", handleDocumentMouseMove, true);
|
||||
window.addEventListener("scroll", hideCellTooltip, true);
|
||||
window.addEventListener("blur", hideCellTooltip);
|
||||
tooltipCleanupHandlers.push(() => table.removeEventListener("mouseover", handleMouseOver));
|
||||
tooltipCleanupHandlers.push(() => table.removeEventListener("mousemove", handleMouseMove));
|
||||
tooltipCleanupHandlers.push(() => table.removeEventListener("mouseout", handleMouseOut));
|
||||
tooltipCleanupHandlers.push(() => document.removeEventListener("mousemove", handleDocumentMouseMove, true));
|
||||
tooltipCleanupHandlers.push(() => window.removeEventListener("scroll", hideCellTooltip, true));
|
||||
tooltipCleanupHandlers.push(() => window.removeEventListener("blur", hideCellTooltip));
|
||||
}
|
||||
|
||||
function refreshCellTooltips(table) {
|
||||
const shouldShowCellTooltip = ["overview", "wide"].includes(tableMode.value);
|
||||
const cells = Array.from(table?.tBodies || []).flatMap((tbody) => Array.from(tbody.querySelectorAll("td")));
|
||||
|
||||
cells.forEach((cell) => {
|
||||
const value = cell.innerText?.trim() || cell.textContent?.trim() || "";
|
||||
const hasBlockingInteractiveContent = Boolean(
|
||||
cell.querySelector("input, select, textarea, a, button:not(.link-button), button.semantic-action-button")
|
||||
);
|
||||
|
||||
if (shouldShowCellTooltip && value && !hasBlockingInteractiveContent) {
|
||||
cell.dataset.tableTooltip = "auto";
|
||||
cell.dataset.tableFullValue = value;
|
||||
if (!cell.getAttribute("aria-label") || cell.dataset.tableAria === "auto") {
|
||||
cell.setAttribute("aria-label", value);
|
||||
cell.dataset.tableAria = "auto";
|
||||
}
|
||||
cell.removeAttribute("title");
|
||||
return;
|
||||
}
|
||||
|
||||
if (cell.dataset.tableTooltip === "auto") {
|
||||
cell.removeAttribute("title");
|
||||
delete cell.dataset.tableTooltip;
|
||||
delete cell.dataset.tableFullValue;
|
||||
if (cell.dataset.tableAria === "auto") {
|
||||
cell.removeAttribute("aria-label");
|
||||
delete cell.dataset.tableAria;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function observeTableForViewMode(table) {
|
||||
if (modeTableObserver || !table) {
|
||||
return;
|
||||
}
|
||||
|
||||
modeTableObserver = new MutationObserver(() => {
|
||||
scheduleApplyTableViewMode();
|
||||
});
|
||||
modeTableObserver.observe(table, { childList: true, characterData: true, subtree: true });
|
||||
}
|
||||
|
||||
function applyTableViewMode(table = findTableAfterControl()) {
|
||||
if (!table) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (modeTable && modeTable !== table) {
|
||||
clearTableViewClasses(modeTable);
|
||||
disconnectModeObserver();
|
||||
}
|
||||
|
||||
modeTable = table;
|
||||
const wrap = getTableViewWrap(table);
|
||||
const isWide = tableMode.value === "wide";
|
||||
|
||||
table.classList.toggle("data-table-overview", !isWide);
|
||||
table.classList.toggle("data-table-wide", isWide);
|
||||
table.dataset.tableViewMode = isWide ? "wide" : "overview";
|
||||
|
||||
wrap?.classList.toggle("table-view-overview", !isWide);
|
||||
wrap?.classList.toggle("table-view-wide", isWide);
|
||||
|
||||
refreshCellTooltips(table);
|
||||
bindCellTooltip(table);
|
||||
observeTableForViewMode(table);
|
||||
}
|
||||
|
||||
function scheduleApplyTableViewMode() {
|
||||
if (modeFrame) {
|
||||
return;
|
||||
}
|
||||
|
||||
modeFrame = window.requestAnimationFrame(() => {
|
||||
modeFrame = 0;
|
||||
applyTableViewMode();
|
||||
});
|
||||
}
|
||||
|
||||
function setTableMode(mode) {
|
||||
const nextMode = normalizeTableMode(mode);
|
||||
if (tableMode.value === nextMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
tableMode.value = nextMode;
|
||||
}
|
||||
|
||||
function getOriginalHeaderLabel(th) {
|
||||
return th.dataset.originalHeaderLabel || th.textContent.trim();
|
||||
}
|
||||
|
||||
function findSortOption(label) {
|
||||
const normalizedHeader = normalizeLabel(label);
|
||||
if (!normalizedHeader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scoredOptions = props.sortOptions
|
||||
.map((option) => {
|
||||
const normalizedOptionLabel = normalizeLabel(option.label);
|
||||
const normalizedOptionKey = normalizeLabel(option.key);
|
||||
let score = 0;
|
||||
|
||||
if (normalizedOptionLabel === normalizedHeader) {
|
||||
score = 100;
|
||||
} else if (normalizedOptionKey === normalizedHeader) {
|
||||
score = 96;
|
||||
} else if (normalizedHeader === "编码" && normalizedOptionLabel.endsWith("编码")) {
|
||||
score = 90;
|
||||
} else if (normalizedHeader === "名称" && normalizedOptionLabel.endsWith("名称")) {
|
||||
score = 90;
|
||||
} else if (normalizedOptionLabel === `${normalizedHeader}名称`) {
|
||||
score = 86;
|
||||
} else if (normalizedOptionLabel === `${normalizedHeader}编码`) {
|
||||
score = 82;
|
||||
} else if (normalizedOptionLabel.startsWith(normalizedHeader) && normalizedOptionLabel.endsWith("名称")) {
|
||||
score = 78;
|
||||
} else if (normalizedOptionLabel.startsWith(normalizedHeader) && normalizedOptionLabel.endsWith("编码")) {
|
||||
score = 68;
|
||||
} else if (normalizedOptionLabel.includes(normalizedHeader)) {
|
||||
score = 58;
|
||||
} else if (normalizedHeader.includes(normalizedOptionLabel)) {
|
||||
score = 52;
|
||||
}
|
||||
|
||||
return { option, score };
|
||||
})
|
||||
.filter((item) => item.score > 0)
|
||||
.sort((left, right) => right.score - left.score);
|
||||
|
||||
return scoredOptions[0]?.option || null;
|
||||
}
|
||||
|
||||
function disconnectHeaderObserver() {
|
||||
if (headerObserver) {
|
||||
headerObserver.disconnect();
|
||||
headerObserver = null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearEnhancedHeaders({ disconnectObserver = true } = {}) {
|
||||
if (disconnectObserver) {
|
||||
disconnectHeaderObserver();
|
||||
}
|
||||
|
||||
cleanupHandlers.forEach((cleanup) => cleanup());
|
||||
cleanupHandlers = [];
|
||||
|
||||
if (!enhancedTable) {
|
||||
return;
|
||||
}
|
||||
|
||||
enhancedTable.querySelectorAll("th.sortable-th").forEach((th) => {
|
||||
const hasEnhancedButton = Boolean(th.querySelector(".sortable-th-button"));
|
||||
const currentHeaderLabel = th.textContent.trim();
|
||||
const originalLabel = hasEnhancedButton ? th.dataset.originalHeaderLabel : currentHeaderLabel || th.dataset.originalHeaderLabel;
|
||||
th.classList.remove("sortable-th", "sortable-th-active", "sortable-th-asc", "sortable-th-desc");
|
||||
delete th.dataset.sortKey;
|
||||
delete th.dataset.originalHeaderLabel;
|
||||
if (originalLabel) {
|
||||
th.textContent = originalLabel;
|
||||
}
|
||||
});
|
||||
enhancedTable = null;
|
||||
}
|
||||
|
||||
function scheduleEnhanceHeaders() {
|
||||
if (isEnhancing || enhanceFrame) {
|
||||
return;
|
||||
}
|
||||
|
||||
enhanceFrame = window.requestAnimationFrame(() => {
|
||||
enhanceFrame = 0;
|
||||
enhanceTableHeaders();
|
||||
});
|
||||
}
|
||||
|
||||
function observeTableHead(table) {
|
||||
disconnectHeaderObserver();
|
||||
|
||||
if (!table?.tHead) {
|
||||
return;
|
||||
}
|
||||
|
||||
headerObserver = new MutationObserver(() => {
|
||||
scheduleEnhanceHeaders();
|
||||
});
|
||||
headerObserver.observe(table.tHead, { childList: true, characterData: true, subtree: true });
|
||||
}
|
||||
|
||||
function toggleSort(sortKey) {
|
||||
if (props.sortKey === sortKey) {
|
||||
emit("update:sortDirection", props.sortDirection === "asc" ? "desc" : "asc");
|
||||
return;
|
||||
}
|
||||
|
||||
emit("update:sortKey", sortKey);
|
||||
emit("update:sortDirection", "asc");
|
||||
}
|
||||
|
||||
function refreshHeaderStates() {
|
||||
const table = enhancedTable || findTableAfterControl();
|
||||
if (!table) {
|
||||
return;
|
||||
}
|
||||
|
||||
table.querySelectorAll("th.sortable-th").forEach((th) => {
|
||||
const isActive = th.dataset.sortKey === props.sortKey;
|
||||
const arrow = th.querySelector(".sort-arrow");
|
||||
th.classList.toggle("sortable-th-active", isActive);
|
||||
th.classList.toggle("sortable-th-asc", isActive && props.sortDirection === "asc");
|
||||
th.classList.toggle("sortable-th-desc", isActive && props.sortDirection === "desc");
|
||||
|
||||
if (arrow) {
|
||||
arrow.textContent = isActive ? (props.sortDirection === "asc" ? "↑" : "↓") : "";
|
||||
arrow.classList.toggle("sort-arrow-active", isActive);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function enhanceTableHeaders() {
|
||||
isEnhancing = true;
|
||||
clearEnhancedHeaders();
|
||||
|
||||
const table = findTableAfterControl();
|
||||
applyTableViewMode(table);
|
||||
|
||||
if (!props.sortOptions.length) {
|
||||
isEnhancing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const headerRows = Array.from(table?.tHead?.rows || []);
|
||||
if (!table || !headerRows.length) {
|
||||
isEnhancing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
enhancedTable = table;
|
||||
|
||||
headerRows
|
||||
.flatMap((row) => Array.from(row.cells))
|
||||
.filter((th) => th.tagName === "TH" && Number(th.colSpan || 1) === 1)
|
||||
.forEach((th) => {
|
||||
const originalLabel = getOriginalHeaderLabel(th);
|
||||
const option = findSortOption(originalLabel);
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
|
||||
th.dataset.originalHeaderLabel = originalLabel;
|
||||
th.dataset.sortKey = option.key;
|
||||
th.classList.add("sortable-th");
|
||||
th.textContent = "";
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "sortable-th-button";
|
||||
button.setAttribute("aria-label", `按${option.label}排序`);
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "sortable-th-label";
|
||||
label.textContent = originalLabel;
|
||||
|
||||
const arrow = document.createElement("span");
|
||||
arrow.className = "sort-arrow";
|
||||
arrow.setAttribute("aria-hidden", "true");
|
||||
arrow.textContent = "";
|
||||
|
||||
button.append(label, arrow);
|
||||
|
||||
const listener = () => toggleSort(option.key);
|
||||
button.addEventListener("click", listener);
|
||||
cleanupHandlers.push(() => button.removeEventListener("click", listener));
|
||||
|
||||
th.appendChild(button);
|
||||
});
|
||||
|
||||
refreshHeaderStates();
|
||||
observeTableHead(table);
|
||||
isEnhancing = false;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(enhanceTableHeaders);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.sortOptions,
|
||||
() => nextTick(enhanceTableHeaders),
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [props.sortKey, props.sortDirection],
|
||||
() => nextTick(refreshHeaderStates)
|
||||
);
|
||||
|
||||
watch(
|
||||
tableMode,
|
||||
(mode) => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(TABLE_VIEW_MODE_STORAGE_KEY, mode);
|
||||
}
|
||||
nextTick(applyTableViewMode);
|
||||
}
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (enhanceFrame) {
|
||||
window.cancelAnimationFrame(enhanceFrame);
|
||||
enhanceFrame = 0;
|
||||
}
|
||||
if (modeFrame) {
|
||||
window.cancelAnimationFrame(modeFrame);
|
||||
modeFrame = 0;
|
||||
}
|
||||
clearTableViewClasses(modeTable);
|
||||
disconnectModeObserver();
|
||||
clearTooltipListeners();
|
||||
clearEnhancedHeaders();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="document-archive-actions" :class="statusClass">
|
||||
<span class="document-archive-status" aria-live="polite">
|
||||
<span class="document-archive-status-dot" aria-hidden="true"></span>
|
||||
{{ statusText }}
|
||||
</span>
|
||||
|
||||
<div class="document-archive-buttons">
|
||||
<button
|
||||
class="document-archive-button document-archive-button-preview"
|
||||
type="button"
|
||||
:disabled="!isReady"
|
||||
:title="isReady ? '预览PDF' : 'PDF未生成,暂不能预览'"
|
||||
@click="emit('preview')"
|
||||
>
|
||||
<span class="document-archive-icon" aria-hidden="true">PDF</span>
|
||||
预览PDF
|
||||
</button>
|
||||
<button
|
||||
class="document-archive-button document-archive-button-download"
|
||||
type="button"
|
||||
:disabled="!isReady"
|
||||
:title="isReady ? '下载PDF' : 'PDF未生成,暂不能下载'"
|
||||
@click="emit('download')"
|
||||
>
|
||||
<span class="document-archive-icon" aria-hidden="true">↓</span>
|
||||
下载PDF
|
||||
</button>
|
||||
<button
|
||||
class="document-archive-button document-archive-button-regenerate"
|
||||
type="button"
|
||||
title="重新生成PDF归档"
|
||||
@click="emit('regenerate')"
|
||||
>
|
||||
<span class="document-archive-icon" aria-hidden="true">↻</span>
|
||||
重新生成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
status: {
|
||||
type: String,
|
||||
default: "未生成"
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(["preview", "download", "regenerate"]);
|
||||
|
||||
const normalizedStatusType = computed(() => {
|
||||
const value = String(props.status ?? "").trim();
|
||||
if (!value || value === "未生成" || value === "missing") return "missing";
|
||||
if (value === "已归档" || value === "ready" || value === "archived") return "ready";
|
||||
if (value === "归档失败" || value === "failed" || value === "error") return "failed";
|
||||
return "missing";
|
||||
});
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (normalizedStatusType.value === "ready") return "已归档";
|
||||
if (normalizedStatusType.value === "failed") return "归档失败";
|
||||
return "未生成";
|
||||
});
|
||||
|
||||
const isReady = computed(() => normalizedStatusType.value === "ready");
|
||||
|
||||
const statusClass = computed(() => ({
|
||||
"document-archive-actions-archived": normalizedStatusType.value === "ready",
|
||||
"document-archive-actions-failed": normalizedStatusType.value === "failed",
|
||||
"document-archive-actions-pending": normalizedStatusType.value === "missing"
|
||||
}));
|
||||
</script>
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<dl class="document-grid" :style="gridStyle">
|
||||
<div
|
||||
v-for="field in fields"
|
||||
:key="field.key"
|
||||
class="document-grid-cell"
|
||||
:class="field.class"
|
||||
>
|
||||
<dt class="document-grid-label">{{ field.label }}</dt>
|
||||
<dd class="document-grid-value">
|
||||
<slot :name="field.key" :field="field">
|
||||
{{ field.value ?? "-" }}
|
||||
</slot>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
fields: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
columns: {
|
||||
type: [Number, String],
|
||||
default: 4
|
||||
}
|
||||
});
|
||||
|
||||
const normalizedColumns = computed(() => {
|
||||
const columns = Number(props.columns);
|
||||
if (!Number.isFinite(columns)) return 4;
|
||||
return Math.min(Math.max(Math.trunc(columns), 1), 6);
|
||||
});
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
"--document-grid-columns": normalizedColumns.value
|
||||
}));
|
||||
</script>
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<section class="document-line-table">
|
||||
<header class="document-line-table-toolbar">
|
||||
<h2 class="document-line-table-title">{{ title }}</h2>
|
||||
<div v-if="$slots.actions" class="document-line-table-actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="document-line-table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
:class="column.class"
|
||||
:style="{ width: column.width }"
|
||||
scope="col"
|
||||
>
|
||||
{{ column.label }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<slot />
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: "明细联单"
|
||||
},
|
||||
columns: {
|
||||
type: Array,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<section class="document-paper-shell" :class="toneClass">
|
||||
<div class="document-paper-stack document-paper-stack-back" aria-hidden="true"></div>
|
||||
<div class="document-paper-stack document-paper-stack-mid" aria-hidden="true"></div>
|
||||
|
||||
<article class="document-paper-sheet">
|
||||
<div class="document-paper-clip" aria-hidden="true">
|
||||
<span class="document-paper-clip-bar"></span>
|
||||
<span class="document-paper-clip-jaw"></span>
|
||||
</div>
|
||||
|
||||
<div class="document-paper-binder" aria-hidden="true">
|
||||
<span
|
||||
v-for="hole in binderHoles"
|
||||
:key="hole"
|
||||
class="document-paper-binder-hole"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<div class="document-retained-mark" aria-label="ERP留存联">ERP留存联</div>
|
||||
|
||||
<header class="document-paper-header">
|
||||
<p class="document-company-name">{{ companyName }}</p>
|
||||
<h1 class="document-paper-title">{{ title }}</h1>
|
||||
<div class="document-number-stamp" :aria-label="documentNo ? `单据编号 ${documentNo}` : '单据编号未填写'">
|
||||
<span class="document-number-label">NO.</span>
|
||||
<strong>{{ documentNo || "待生成" }}</strong>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="document-paper-body">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<footer class="document-signature-footer" aria-label="签核栏">
|
||||
<div
|
||||
v-for="label in signatureLabels"
|
||||
:key="label"
|
||||
class="document-signature-cell"
|
||||
>
|
||||
<span class="document-signature-label">{{ label }}</span>
|
||||
<span class="document-signature-line"></span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<div class="document-paper-action-host" data-warehouse-document-action-host></div>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
documentNo: {
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
companyName: {
|
||||
type: String,
|
||||
default: "嘉恒仓库 ERP"
|
||||
},
|
||||
tone: {
|
||||
type: String,
|
||||
default: "red"
|
||||
},
|
||||
signatureLabels: {
|
||||
type: Array,
|
||||
default: () => ["制单", "审核", "仓库", "财务"]
|
||||
}
|
||||
});
|
||||
|
||||
const binderHoles = [1, 2, 3, 4, 5];
|
||||
|
||||
const toneClass = computed(() => ({
|
||||
"document-paper-tone-red": props.tone !== "green",
|
||||
"document-paper-tone-green": props.tone === "green"
|
||||
}));
|
||||
</script>
|
||||
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<span ref="anchorRef" class="warehouse-document-action-anchor" aria-hidden="true"></span>
|
||||
|
||||
<Teleport :to="actionHost" :disabled="!actionHost">
|
||||
<div
|
||||
class="warehouse-document-action-bar warehouse-document-action-bar--shared"
|
||||
:class="{ 'warehouse-document-action-bar--with-state': $slots.state }"
|
||||
@click.capture="handleActionClick"
|
||||
>
|
||||
<div v-if="$slots.state" class="warehouse-document-action-state">
|
||||
<slot name="state" />
|
||||
</div>
|
||||
|
||||
<div class="warehouse-document-action-buttons">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, onMounted, ref } from "vue";
|
||||
|
||||
const anchorRef = ref(null);
|
||||
const actionHost = ref(null);
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
actionHost.value = anchorRef.value
|
||||
?.closest?.("form")
|
||||
?.querySelector?.("[data-warehouse-document-action-host]") || null;
|
||||
});
|
||||
|
||||
function handleActionClick(event) {
|
||||
const target = event.target;
|
||||
const submitter = target?.closest?.("button[type='submit'], input[type='submit']");
|
||||
if (!submitter || submitter.disabled) {
|
||||
return;
|
||||
}
|
||||
const form = anchorRef.value?.closest?.("form");
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<form class="warehouse-document-form" @submit.prevent="emit('submit')">
|
||||
<DocumentPaper
|
||||
:title="title"
|
||||
:document-no="documentNo"
|
||||
:company-name="companyName"
|
||||
:tone="tone"
|
||||
>
|
||||
<div class="warehouse-document-form-meta" aria-label="仓库单据基础信息">
|
||||
<span>仓库联单</span>
|
||||
<strong>{{ companyName }}</strong>
|
||||
<span>业务日期</span>
|
||||
<strong>{{ businessDate || "-" }}</strong>
|
||||
</div>
|
||||
|
||||
<main class="warehouse-document-body">
|
||||
<slot />
|
||||
</main>
|
||||
</DocumentPaper>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import DocumentPaper from "./DocumentPaper.vue";
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
documentNo: {
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
businessDate: {
|
||||
type: String,
|
||||
default: ""
|
||||
},
|
||||
tone: {
|
||||
type: String,
|
||||
default: "green"
|
||||
},
|
||||
companyName: {
|
||||
type: String,
|
||||
default: "嘉恒仓库"
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(["submit"]);
|
||||
</script>
|
||||
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<section class="warehouse-document-section">
|
||||
<header class="warehouse-document-section-head">
|
||||
<span class="warehouse-document-section-index">{{ index }}</span>
|
||||
<strong>{{ title }}</strong>
|
||||
</header>
|
||||
|
||||
<div class="warehouse-document-section-grid">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
index: {
|
||||
type: [Number, String],
|
||||
required: true
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user