feat(@vben/web-antd): 手动创建工单分步弹窗 + code review 修复

新增手动创建工单功能,分3步:选类型→选区域(TreeSelect全路径)→填表单。
优先级使用字典值,按工单类型动态显示保洁/安保专属字段。

- 新增 create-order-form.vue 分步创建弹窗组件
- work-order/index.vue 挂载弹窗替换占位
- card-view.vue 补充 OpsOrderCenterApi 类型导入
- 还原 auth.ts 误删的 visitTenantId 逻辑

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lzh
2026-03-27 16:52:15 +08:00
parent 84cb4930fd
commit cc2d7a0a04
3 changed files with 644 additions and 37 deletions

View File

@@ -29,6 +29,7 @@ import {
import AssignForm from './modules/assign-form.vue';
import CancelForm from './modules/cancel-form.vue';
import CardView from './modules/card-view.vue';
import CreateOrderForm from './modules/create-order-form.vue';
import SecurityAssignForm from './modules/security-assign-form.vue';
import StatsBar from './modules/stats-bar.vue';
import UpgradePriorityForm from './modules/upgrade-priority-form.vue';
@@ -74,6 +75,11 @@ const [CancelFormModal, cancelFormModalApi] = useVbenModal({
destroyOnClose: true,
});
const [CreateOrderFormModal, createOrderFormModalApi] = useVbenModal({
connectedComponent: CreateOrderForm,
destroyOnClose: true,
});
// 查询参数
const queryParams = ref({
orderType: undefined as OpsOrderCenterApi.OrderType | undefined,
@@ -164,6 +170,7 @@ function handleUpgrade(row: OpsOrderCenterApi.OrderItem) {
.setData({
orderId: row.id,
orderCode: row.orderCode,
orderType: row.orderType,
currentPriority: row.priority,
})
.open();
@@ -175,6 +182,7 @@ function handleCancel(row: OpsOrderCenterApi.OrderItem) {
.setData({
orderId: row.id,
orderCode: row.orderCode,
orderType: row.orderType,
title: row.title,
})
.open();
@@ -313,6 +321,7 @@ onActivated(() => {
<SecurityAssignFormModal @success="handleRefresh" />
<UpgradePriorityFormModal @success="handleRefresh" />
<CancelFormModal @success="handleRefresh" />
<CreateOrderFormModal @success="handleRefresh" />
<!-- 区域筛选抽屉 -->
<AreaFilterDrawer
@@ -402,7 +411,7 @@ onActivated(() => {
<Button
type="primary"
class="create-btn"
@click="() => message.info('手动创建工单功能开发中')"
@click="createOrderFormModalApi.open()"
>
<IconifyIcon icon="solar:add-circle-bold" />
<span>创建工单</span>

View File

@@ -20,10 +20,11 @@ import {
} from 'ant-design-vue';
import dayjs from 'dayjs';
import { getOrderPage } from '#/api/ops/order-center';
import { type OpsOrderCenterApi, getOrderPage } from '#/api/ops/order-center';
import {
ORDER_TYPE_COLOR_MAP,
ORDER_TYPE_ICON_MAP,
ORDER_TYPE_TEXT_MAP,
STATUS_COLOR_MAP,
STATUS_ICON_MAP,
@@ -292,24 +293,49 @@ defineExpose({
'order-card--urgent': isUrgent(item.priority),
'order-card--terminal': isTerminal(item.status),
}"
:style="{
'--type-color': ORDER_TYPE_COLOR_MAP[item.orderType]?.border || '#d9d9d9',
'--type-color-bg': ORDER_TYPE_COLOR_MAP[item.orderType]?.bg || 'rgb(0 0 0 / 3%)',
}"
@click="emit('detail', item.id)"
>
<!-- 头部状态 + 优先级 -->
<!-- 头部类型标识 + 状态 + 优先级 -->
<div class="card-header">
<div
class="status-info"
:style="{
'--status-color': STATUS_COLOR_MAP[item.status]?.text,
'--status-bg': STATUS_COLOR_MAP[item.status]?.bg,
}"
>
<IconifyIcon
:icon="STATUS_ICON_MAP[item.status] || 'solar:circle-bold'"
class="status-icon"
/>
<span class="status-text">{{
STATUS_TEXT_MAP[item.status]
}}</span>
<div class="card-header-left">
<div
class="type-badge"
:style="{
backgroundColor:
ORDER_TYPE_COLOR_MAP[item.orderType]?.bg,
color: ORDER_TYPE_COLOR_MAP[item.orderType]?.text,
}"
>
<IconifyIcon
:icon="
ORDER_TYPE_ICON_MAP[item.orderType] ||
'solar:widget-bold-duotone'
"
class="type-badge-icon"
/>
<span>{{ ORDER_TYPE_TEXT_MAP[item.orderType] }}</span>
</div>
<div
class="status-info"
:style="{
'--status-color': STATUS_COLOR_MAP[item.status]?.text,
'--status-bg': STATUS_COLOR_MAP[item.status]?.bg,
}"
>
<IconifyIcon
:icon="
STATUS_ICON_MAP[item.status] || 'solar:circle-bold'
"
class="status-icon"
/>
<span class="status-text">{{
STATUS_TEXT_MAP[item.status]
}}</span>
</div>
</div>
<div
class="priority-tag"
@@ -400,17 +426,8 @@ defineExpose({
<!-- 底部:元信息 + 操作按钮 -->
<div class="card-footer">
<!-- 左侧:类型 + 时间 -->
<!-- 左侧:时间 -->
<div class="card-meta">
<span
class="type-tag"
:style="{
backgroundColor: ORDER_TYPE_COLOR_MAP[item.orderType]?.bg,
color: ORDER_TYPE_COLOR_MAP[item.orderType]?.text,
}"
>
{{ ORDER_TYPE_TEXT_MAP[item.orderType] }}
</span>
<span class="create-time">{{
formatTime(item.createTime)
}}</span>
@@ -534,18 +551,42 @@ defineExpose({
// 卡片
.order-card {
--type-color: #d9d9d9;
position: relative;
display: flex;
flex-direction: column;
height: 100%;
min-height: 200px;
padding: 12px;
overflow: hidden;
cursor: pointer;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
transition: all 0.2s;
// 底层渐变背景 按工单类型着色
&::after {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
content: '';
background: linear-gradient(
0deg,
var(--type-color-bg, rgb(0 0 0 / 3%)) 0%,
transparent 50%
);
border-radius: 8px;
}
// 所有直接子元素提升到渐变层之上
> * {
position: relative;
z-index: 1;
}
&:hover {
border-color: #d9d9d9;
box-shadow: 0 2px 8px rgb(0 0 0 / 6%);
@@ -587,6 +628,27 @@ defineExpose({
margin-bottom: 8px;
}
.card-header-left {
display: flex;
gap: 6px;
align-items: center;
}
// 工单类型徽章
.type-badge {
display: flex;
gap: 4px;
align-items: center;
padding: 2px 8px;
font-size: 12px;
font-weight: 600;
border-radius: 4px;
.type-badge-icon {
font-size: 14px;
}
}
.status-info {
--status-color: #6b5e52;
--status-bg: #f0ede8;
@@ -731,16 +793,6 @@ defineExpose({
align-items: center;
min-width: 0;
.type-tag {
flex-shrink: 0;
padding: 1px 6px;
margin: 0;
font-size: 11px;
font-weight: 500;
line-height: 16px;
border-radius: 4px;
}
.order-code {
flex-shrink: 0;
font-family: 'SF Mono', Monaco, monospace;

View File

@@ -0,0 +1,546 @@
<script setup lang="ts">
import type { OpsAreaApi } from '#/api/ops/area';
import { computed, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { DICT_TYPE } from '@vben/constants';
import { IconifyIcon } from '@vben/icons';
import { useDictStore } from '@vben/stores';
import { handleTree } from '@vben/utils';
import { Button, message, Steps, TreeSelect } from 'ant-design-vue';
import { useVbenForm, z } from '#/adapter/form';
import { getAreaTree } from '#/api/ops/area';
import { manualCreateOrder } from '#/api/ops/cleaning';
import { manualCreateSecurityOrder } from '#/api/ops/security';
import { CLEANING_TYPE_OPTIONS } from '../data';
defineOptions({ name: 'CreateOrderForm' });
const emit = defineEmits<{ success: [] }>();
// ==================== 状态 ====================
const dictStore = useDictStore();
const priorityOptions = computed(() => {
const opts = dictStore.getDictOptions(DICT_TYPE.OPS_ORDER_PRIORITY);
return opts.map((item) => ({
label: item.label,
value: Number(item.value),
}));
});
const currentStep = ref(0);
const orderType = ref<'CLEAN' | 'SECURITY'>();
const selectedAreaId = ref<number>();
const selectedAreaPath = ref<string>('');
const loading = ref(false);
// 区域树 + 平铺列表(用于计算全路径)
const areaTreeData = ref<any[]>([]);
const areaFlatList = ref<OpsAreaApi.BusArea[]>([]);
const areaTreeLoading = ref(false);
// ==================== 表单 ====================
const [Form, formApi] = useVbenForm({
schema: [
// 隐藏字段:用于驱动保洁/安保条件字段的显隐
{
fieldName: '_orderType',
label: '',
component: 'Input',
dependencies: { triggerFields: [''], show: () => false },
},
{
fieldName: 'title',
label: '工单标题',
component: 'Input',
componentProps: {
placeholder: '请输入工单标题',
maxLength: 100,
},
rules: z.string().min(2, '标题至少2个字符').max(100, '标题不能超过100字符'),
},
{
fieldName: 'description',
label: '工单描述',
component: 'Textarea',
componentProps: {
placeholder: '请输入工单描述(选填)',
rows: 3,
maxLength: 500,
},
},
{
fieldName: 'priority',
label: '优先级',
component: 'Select',
componentProps: () => ({
placeholder: '请选择优先级',
options: priorityOptions.value,
}),
defaultValue: 2,
},
// 保洁专属
{
fieldName: 'cleaningType',
label: '保洁类型',
component: 'Select',
componentProps: {
placeholder: '请选择保洁类型',
options: CLEANING_TYPE_OPTIONS,
},
dependencies: {
triggerFields: ['_orderType'],
show: (values) => values._orderType === 'CLEAN',
},
},
{
fieldName: 'expectedDuration',
label: '预计时长(分钟)',
component: 'InputNumber',
componentProps: {
placeholder: '请输入预计作业时长',
min: 1,
max: 480,
style: { width: '100%' },
},
dependencies: {
triggerFields: ['_orderType'],
show: (values) => values._orderType === 'CLEAN',
},
},
// 安保专属:相关图片(选填)
{
fieldName: 'imageUrl',
label: '相关图片',
component: 'ImageUpload',
componentProps: {
maxNumber: 1,
maxSize: 5,
showDescription: false,
},
dependencies: {
triggerFields: ['_orderType'],
show: (values) => values._orderType === 'SECURITY',
},
},
],
showDefaultActions: false,
});
// ==================== 弹窗 ====================
const [Modal, modalApi] = useVbenModal({
onOpenChange: async (isOpen) => {
if (isOpen) {
resetForm();
await loadAreaTree();
}
},
onConfirm: handleSubmit,
});
// ==================== 计算属性 ====================
const canNext = computed(() => {
if (currentStep.value === 0) return !!orderType.value;
if (currentStep.value === 1) return !!selectedAreaId.value;
return true;
});
const stepItems = computed(() => [
{ title: '工单类型' },
{ title: '选择区域' },
{ title: '填写信息' },
]);
// ==================== 方法 ====================
function resetForm() {
currentStep.value = 0;
orderType.value = undefined;
selectedAreaId.value = undefined;
selectedAreaPath.value = '';
formApi.resetForm();
}
async function loadAreaTree() {
areaTreeLoading.value = true;
try {
const data = await getAreaTree({ isActive: true });
const list = (
Array.isArray(data) ? data : ((data as any)?.list ?? [])
) as OpsAreaApi.BusArea[];
areaFlatList.value = list;
areaTreeData.value = handleTree(list, 'id', 'parentId', 'children');
} catch {
areaFlatList.value = [];
areaTreeData.value = [];
} finally {
areaTreeLoading.value = false;
}
}
function selectType(type: 'CLEAN' | 'SECURITY') {
orderType.value = type;
// 同步到表单隐藏字段,驱动 dependencies 重新计算显隐
formApi.setValues({ _orderType: type });
}
/** 根据 id 向上查找,拼接 "父级 / 子级" 全路径 */
function getAreaPath(id: number): string {
const map = new Map(areaFlatList.value.map((a) => [a.id, a]));
const parts: string[] = [];
let current = map.get(id);
while (current) {
parts.unshift(current.areaName);
current = current.parentId ? map.get(current.parentId) : undefined;
}
return parts.join(' / ');
}
function handleAreaChange(value: number | undefined) {
selectedAreaId.value = value;
selectedAreaPath.value = value ? getAreaPath(value) : '';
}
function handleNext() {
if (canNext.value && currentStep.value < 2) {
currentStep.value++;
}
}
function handlePrev() {
if (currentStep.value > 0) {
currentStep.value--;
}
}
async function handleSubmit() {
if (currentStep.value < 2) {
handleNext();
return;
}
const { valid } = await formApi.validate();
if (!valid) return;
if (!orderType.value || !selectedAreaId.value) {
message.warning('请先完成前面的步骤');
currentStep.value = orderType.value ? 1 : 0;
return;
}
// 使用 getValues 获取完整表单值(含 defaultValue
const values = await formApi.getValues();
loading.value = true;
modalApi.setState({ confirmLoading: true });
try {
if (orderType.value === 'CLEAN') {
await manualCreateOrder({
title: values.title,
description: values.description || undefined,
priority: values.priority ?? 2,
areaId: selectedAreaId.value,
cleaningType: values.cleaningType || undefined,
expectedDuration: values.expectedDuration || undefined,
});
} else {
await manualCreateSecurityOrder({
title: values.title,
description: values.description || undefined,
priority: values.priority ?? 2,
areaId: selectedAreaId.value,
imageUrl: values.imageUrl || undefined,
sourceType: 'MANUAL',
});
}
message.success('工单创建成功');
modalApi.close();
emit('success');
} finally {
loading.value = false;
modalApi.setState({ confirmLoading: false });
}
}
// 监听 step 变化,动态设置确认按钮文本
watch(currentStep, (step) => {
modalApi.setState({
confirmText: step === 2 ? '提交' : '下一步',
});
});
</script>
<template>
<Modal title="创建工单" class="w-[580px]">
<!-- 步骤条 -->
<Steps :current="currentStep" :items="stepItems" size="small" class="co-steps" />
<!-- Step 1: 选择工单类型 -->
<div v-show="currentStep === 0" class="co-step-content">
<div class="co-type-grid">
<div
class="co-type-card"
:class="{ 'co-type-card--active': orderType === 'CLEAN' }"
@click="selectType('CLEAN')"
>
<div class="co-type-icon" style="background: #e5faf2; color: #047857">
<IconifyIcon icon="solar:bath-bold-duotone" class="size-6" />
</div>
<div class="co-type-info">
<div class="co-type-title">保洁工单</div>
<div class="co-type-desc">日常/深度/应急保洁任务</div>
</div>
<IconifyIcon
v-if="orderType === 'CLEAN'"
icon="lucide:check-circle-2"
class="co-type-check"
/>
</div>
<div
class="co-type-card"
:class="{ 'co-type-card--active': orderType === 'SECURITY' }"
@click="selectType('SECURITY')"
>
<div class="co-type-icon" style="background: #eef0ff; color: #3730a3">
<IconifyIcon icon="solar:shield-bold-duotone" class="size-6" />
</div>
<div class="co-type-info">
<div class="co-type-title">安保工单</div>
<div class="co-type-desc">入侵/离岗/火焰等告警处理</div>
</div>
<IconifyIcon
v-if="orderType === 'SECURITY'"
icon="lucide:check-circle-2"
class="co-type-check"
/>
</div>
</div>
</div>
<!-- Step 2: 选择区域 -->
<div v-show="currentStep === 1" class="co-step-content">
<div class="co-area-section">
<div class="co-section-label">选择工单所属区域</div>
<TreeSelect
v-model:value="selectedAreaId"
:tree-data="areaTreeData"
:field-names="{ label: 'areaName', value: 'id', children: 'children' }"
:loading="areaTreeLoading"
tree-default-expand-all
show-search
:tree-node-filter-prop="'areaName'"
placeholder="搜索并选择区域"
allow-clear
style="width: 100%"
size="large"
@change="handleAreaChange"
/>
<div v-if="selectedAreaId" class="co-area-selected">
<IconifyIcon icon="solar:map-point-bold-duotone" class="co-area-icon" />
<span>已选择: {{ selectedAreaPath }}</span>
</div>
</div>
</div>
<!-- Step 3: 填写工单信息 -->
<div v-show="currentStep === 2" class="co-step-content">
<!-- 已选信息概要 -->
<div class="co-summary">
<span class="co-summary-tag" :style="orderType === 'CLEAN'
? 'background:#e5faf2;color:#047857'
: 'background:#eef0ff;color:#3730a3'
">
{{ orderType === 'CLEAN' ? '保洁' : '安保' }}
</span>
<span class="co-summary-area">
<IconifyIcon icon="solar:map-point-bold-duotone" class="co-summary-icon" />
{{ selectedAreaPath || '未选择区域' }}
</span>
</div>
<Form />
</div>
<!-- 底部导航按钮 -->
<template #prepend-footer>
<Button v-if="currentStep > 0" @click="handlePrev">上一步</Button>
</template>
</Modal>
</template>
<style scoped>
.co-steps {
padding: 0 16px;
margin-bottom: 20px;
}
.co-step-content {
min-height: 200px;
}
/* 类型选择卡片 */
.co-type-grid {
display: flex;
flex-direction: column;
gap: 12px;
}
.co-type-card {
position: relative;
display: flex;
gap: 14px;
align-items: center;
padding: 16px;
cursor: pointer;
border: 2px solid #f0f0f0;
border-radius: 10px;
transition: all 0.2s;
}
.co-type-card:hover {
border-color: #d9d9d9;
}
.co-type-card--active {
background: #f0f7ff;
border-color: #1677ff;
}
.co-type-icon {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 10px;
}
.co-type-info {
flex: 1;
}
.co-type-title {
font-size: 15px;
font-weight: 600;
color: #1f2937;
}
.co-type-desc {
margin-top: 2px;
font-size: 12px;
color: #9ca3af;
}
.co-type-check {
position: absolute;
top: 12px;
right: 12px;
font-size: 20px;
color: #1677ff;
}
/* 区域选择 */
.co-area-section {
padding: 8px 0;
}
.co-section-label {
margin-bottom: 10px;
font-size: 14px;
font-weight: 500;
color: #374151;
}
.co-area-selected {
display: flex;
gap: 6px;
align-items: center;
padding: 8px 12px;
margin-top: 12px;
font-size: 13px;
color: #047857;
background: #f0fdf4;
border-radius: 6px;
}
.co-area-icon {
color: #10b981;
}
/* 已选概要 */
.co-summary {
display: flex;
gap: 10px;
align-items: center;
padding: 8px 12px;
margin-bottom: 16px;
background: #fafafa;
border-radius: 8px;
}
.co-summary-tag {
padding: 2px 8px;
font-size: 12px;
font-weight: 500;
border-radius: 4px;
}
.co-summary-area {
display: flex;
gap: 4px;
align-items: center;
font-size: 13px;
color: #6b7280;
}
.co-summary-icon {
font-size: 14px;
color: #9ca3af;
}
/* dark mode */
html.dark .co-type-card {
border-color: rgb(255 255 255 / 12%);
}
html.dark .co-type-card:hover {
border-color: rgb(255 255 255 / 25%);
}
html.dark .co-type-card--active {
background: rgb(22 119 255 / 10%);
border-color: #1677ff;
}
html.dark .co-type-title {
color: rgb(255 255 255 / 88%);
}
html.dark .co-type-desc {
color: rgb(255 255 255 / 45%);
}
html.dark .co-section-label {
color: rgb(255 255 255 / 85%);
}
html.dark .co-area-selected {
color: #34d399;
background: rgb(16 185 129 / 10%);
}
html.dark .co-summary {
background: rgb(255 255 255 / 6%);
}
html.dark .co-summary-area {
color: rgb(255 255 255 / 65%);
}
</style>