Compare commits

...

6 Commits

Author SHA1 Message Date
lzh
60f4912401 style(@vben/web-antd): lint 自动格式化修正
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:22:29 +08:00
lzh
bdb06e761a refactor(@vben/web-antd): 重写升级优先级和取消工单弹窗,统一布局风格
- 移除 useVbenForm 避免弹窗内布局冲突,改用原生 Input.TextArea
- 统一为图标+标签+值的信息行布局,与安保派单弹窗风格一致
- 提交时增加 confirmLoading 状态反馈及 maxLength 保护校验
- 支持暗色模式样式适配

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:21:58 +08:00
lzh
ed50dc3f7e feat(@vben/web-antd): 安保工单告警图片优化及专用派单表单
- 告警图片改为缩略图展示(200x150),添加圆角和悬停放大效果
- 新增安保专用派单表单,基于分页用户列表选择执行人
- 工单中心列表页和详情页均根据工单类型路由到对应派单表单
- 搜索防抖300ms,组件卸载时清理定时器防止内存泄漏

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:21:29 +08:00
lzh
5e053c6366 fix(@vben/web-antd): 移除文件配置自定义域名的必填校验
自定义域名为可选项,移除多余的 required 规则。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:21:10 +08:00
lzh
bea5a82825 feat(@vben/web-antd): 重构个人中心用户资料与社交绑定页面
- 用户资料页改为卡片式布局,头像居中展示角色标签
- 社交绑定页替换表格为卡片列表,支持已绑定详情展开
- 新增微信小程序社交类型枚举,小程序端绑定入口置灰提示
- 头像上传兼容 server/client 两种模式的返回值
- 社交绑定列表增加类型安全(SocialBindItem interface)
- 隐藏暂不支持的钉钉和企业微信绑定入口

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:19:56 +08:00
lzh
666f25404d chore(@vben/web-antd): 切换文件上传类型为前端直连并修复生产环境注释编码
将 VITE_UPLOAD_TYPE 从 server 改为 client(前端直连S3上传),
修复 .env.production 中因编码损坏导致的中文注释乱码。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:19:29 +08:00
12 changed files with 996 additions and 398 deletions

View File

@@ -8,7 +8,7 @@ VITE_BASE_URL=http://127.0.0.1:48080
# 接口地址
VITE_GLOB_API_URL=/admin-api
# 文件上传类型server - 后端上传, client - 前端直连上传仅支持S3服务
VITE_UPLOAD_TYPE=server
VITE_UPLOAD_TYPE=client
# 是否打开 devtoolstrue 为打开false 为关闭
VITE_DEVTOOLS=false

View File

@@ -4,16 +4,17 @@ VITE_BASE=/
VITE_BASE_URL=/admin-api
# 接口地址
VITE_GLOB_API_URL=/admin-api
# 文件上传类型server - 后端上传<EFBFBD><EFBFBD>?client - 前端直连上传仅支持S3服务
VITE_UPLOAD_TYPE=server
# 文件上传类型server - 后端上传client - 前端直连上传仅支持S3服务
VITE_UPLOAD_TYPE=client
# 是否开启压缩,可以设置<EFBFBD><EFBFBD>?none, brotli, gzip
# 是否开启压缩,可以设置none, brotli, gzip
VITE_COMPRESS=none
# 是否开<EFBFBD><EFBFBD>?PWA
# 是否开PWA
VITE_PWA=false
# vue-router 的模<EFBFBD><EFBFBD>?VITE_ROUTER_HISTORY=hash
# vue-router 的模
VITE_ROUTER_HISTORY=hash
# 是否注入全局loading
VITE_INJECT_APP_LOADING=true

View File

@@ -7,7 +7,7 @@ import { IconifyIcon } from '@vben/icons';
import { preferences } from '@vben/preferences';
import { formatDateTime } from '@vben/utils';
import { Descriptions, DescriptionsItem, Tooltip } from 'ant-design-vue';
import { Divider, Tag, Tooltip } from 'ant-design-vue';
import { updateUserProfile } from '#/api/system/user/profile';
import { CropperAvatar } from '#/components/cropper';
@@ -36,113 +36,157 @@ async function handelUpload({
const { httpRequest } = useUpload();
// 将 Blob 转换为 File
const fileObj = new File([file], filename, { type: file.type });
const avatar = await httpRequest(fileObj);
const result = await httpRequest(fileObj);
// 2. 更新用户头像
await updateUserProfile({ avatar });
const avatarUrl = typeof result === 'string' ? result : result?.url;
if (avatarUrl) {
await updateUserProfile({ avatar: avatarUrl });
}
}
</script>
<template>
<div v-if="profile">
<div class="flex flex-col items-center">
<div v-if="profile" class="profile-user">
<!-- 头像和基本信息 -->
<div class="flex flex-col items-center pb-4">
<Tooltip title="点击上传头像">
<CropperAvatar
:show-btn="false"
:upload-api="handelUpload"
:value="avatar"
:width="120"
:width="100"
@change="emit('success')"
/>
</Tooltip>
<h3 class="mt-3 text-lg font-semibold">
{{ profile.nickname || profile.username }}
</h3>
<div class="mt-1 flex flex-wrap justify-center gap-1">
<Tag v-for="role in profile.roles" :key="role.id" color="blue">
{{ role.name }}
</Tag>
</div>
</div>
<div class="mt-8">
<Descriptions :column="2">
<DescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon icon="ant-design:user-outlined" class="mr-1" />
用户账号
</div>
</template>
{{ profile.username }}
</DescriptionsItem>
<DescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon
icon="ant-design:user-switch-outlined"
class="mr-1"
/>
所属角色
</div>
</template>
{{ profile.roles.map((role) => role.name).join(',') }}
</DescriptionsItem>
<DescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon icon="ant-design:phone-outlined" class="mr-1" />
手机号码
</div>
</template>
{{ profile.mobile }}
</DescriptionsItem>
<DescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon icon="ant-design:mail-outlined" class="mr-1" />
用户邮箱
</div>
</template>
{{ profile.email }}
</DescriptionsItem>
<DescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon icon="ant-design:team-outlined" class="mr-1" />
所属部门
</div>
</template>
{{ profile.dept?.name }}
</DescriptionsItem>
<DescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon
icon="ant-design:usergroup-add-outlined"
class="mr-1"
/>
所属岗位
</div>
</template>
<Divider class="!my-3" />
<!-- 详细信息列表 -->
<div class="space-y-3 px-2">
<div class="profile-item">
<div class="profile-item-label">
<IconifyIcon icon="ant-design:user-outlined" class="mr-2 text-base" />
<span>用户账号</span>
</div>
<span class="profile-item-value">{{ profile.username }}</span>
</div>
<div class="profile-item">
<div class="profile-item-label">
<IconifyIcon
icon="ant-design:phone-outlined"
class="mr-2 text-base"
/>
<span>手机号码</span>
</div>
<span class="profile-item-value">{{ profile.mobile || '-' }}</span>
</div>
<div class="profile-item">
<div class="profile-item-label">
<IconifyIcon icon="ant-design:mail-outlined" class="mr-2 text-base" />
<span>用户邮箱</span>
</div>
<span class="profile-item-value">{{ profile.email || '-' }}</span>
</div>
<div class="profile-item">
<div class="profile-item-label">
<IconifyIcon icon="ant-design:team-outlined" class="mr-2 text-base" />
<span>所属部门</span>
</div>
<span class="profile-item-value">{{ profile.dept?.name || '-' }}</span>
</div>
<div class="profile-item">
<div class="profile-item-label">
<IconifyIcon
icon="ant-design:usergroup-add-outlined"
class="mr-2 text-base"
/>
<span>所属岗位</span>
</div>
<span class="profile-item-value">
{{
profile.posts && profile.posts.length > 0
? profile.posts.map((post) => post.name).join(',')
: '-'
}}
</DescriptionsItem>
<DescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon
icon="ant-design:clock-circle-outlined"
class="mr-1"
/>
创建时间
</div>
</template>
</span>
</div>
<div class="profile-item">
<div class="profile-item-label">
<IconifyIcon
icon="ant-design:clock-circle-outlined"
class="mr-2 text-base"
/>
<span>创建时间</span>
</div>
<span class="profile-item-value">
{{ formatDateTime(profile.createTime) }}
</DescriptionsItem>
<DescriptionsItem>
<template #label>
<div class="flex items-center">
<IconifyIcon icon="ant-design:login-outlined" class="mr-1" />
登录时间
</div>
</template>
</span>
</div>
<div class="profile-item">
<div class="profile-item-label">
<IconifyIcon
icon="ant-design:login-outlined"
class="mr-2 text-base"
/>
<span>登录时间</span>
</div>
<span class="profile-item-value">
{{ formatDateTime(profile.loginDate) }}
</DescriptionsItem>
</Descriptions>
</span>
</div>
</div>
</div>
</template>
<style scoped>
.profile-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 4px;
border-bottom: 1px solid rgb(0 0 0 / 6%);
}
.dark .profile-item {
border-bottom-color: rgb(255 255 255 / 8%);
}
.profile-item:last-child {
border-bottom: none;
}
.profile-item-label {
display: flex;
align-items: center;
font-size: 14px;
color: rgb(0 0 0 / 65%);
}
.dark .profile-item-label {
color: rgb(255 255 255 / 65%);
}
.profile-item-value {
font-size: 14px;
color: rgb(0 0 0 / 85%);
}
.dark .profile-item-value {
color: rgb(255 255 255 / 85%);
}
</style>

View File

@@ -1,5 +1,4 @@
<script setup lang="tsx">
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
<script setup lang="ts">
import type { SystemSocialUserApi } from '#/api/system/social/user';
import { computed, onMounted, ref } from 'vue';
@@ -8,125 +7,80 @@ import { useRoute } from 'vue-router';
import { confirm } from '@vben/common-ui';
import { DICT_TYPE, SystemUserSocialTypeEnum } from '@vben/constants';
import { getDictLabel } from '@vben/hooks';
import { getUrlValue } from '@vben/utils';
import { $t } from '@vben/locales';
import { formatDateTime, getUrlValue } from '@vben/utils';
import { Button, Card, Image, message } from 'ant-design-vue';
import { Avatar, Button, Image, message, Tag, Tooltip } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { socialAuthRedirect } from '#/api/core/auth';
import {
getBindSocialUserList,
socialBind,
socialUnbind,
} from '#/api/system/social/user';
import { $t } from '#/locales';
const emit = defineEmits<{
(e: 'update:activeName', v: string): void;
}>();
const route = useRoute();
/** 已经绑定的平台 */
const bindList = ref<SystemSocialUserApi.SocialUser[]>([]);
const allBindList = computed<any[]>(() => {
return Object.values(SystemUserSocialTypeEnum).map((social) => {
const socialUser = bindList.value.find((item) => item.type === social.type);
return {
...social,
socialUser,
};
});
});
function useGridColumns(): VxeTableGridOptions['columns'] {
return [
{
field: 'type',
title: '绑定平台',
minWidth: 100,
cellRender: {
name: 'CellDict',
props: { type: DICT_TYPE.SYSTEM_SOCIAL_TYPE },
},
},
{
field: 'openid',
title: '标识',
minWidth: 180,
},
{
field: 'nickname',
title: '昵称',
minWidth: 180,
},
{
field: 'operation',
title: '操作',
minWidth: 80,
align: 'center',
fixed: 'right',
slots: {
default: ({ row }: { row: SystemSocialUserApi.SocialUser }) => {
return (
<Button onClick={() => onUnbind(row)} type="link">
解绑
</Button>
);
},
},
},
];
/** 暂不支持钉钉和企业微信,后续开放时移除此过滤 */
const HIDDEN_SOCIAL_TYPES = new Set([
SystemUserSocialTypeEnum.DINGTALK.type,
SystemUserSocialTypeEnum.WECHAT_ENTERPRISE.type,
]);
interface SocialBindItem {
title: string;
type: number;
source: string;
img: string;
socialUser?: SystemSocialUserApi.SocialUser;
}
const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: {
columns: useGridColumns(),
minHeight: 0,
keepSource: true,
proxyConfig: {
ajax: {
query: async () => {
bindList.value = await getBindSocialUserList();
return bindList.value;
},
},
},
rowConfig: {
keyField: 'id',
},
pagerConfig: {
enabled: false,
},
toolbarConfig: {
enabled: false,
},
} as VxeTableGridOptions<SystemSocialUserApi.SocialUser>,
const allBindList = computed<SocialBindItem[]>(() => {
return Object.values(SystemUserSocialTypeEnum)
.filter((social) => !HIDDEN_SOCIAL_TYPES.has(social.type))
.map((social) => {
const socialUser = bindList.value.find(
(item) => item.type === social.type,
);
return {
...social,
socialUser,
};
});
});
/** 加载绑定列表 */
async function loadBindList() {
bindList.value = await getBindSocialUserList();
}
/** 解绑账号 */
function onUnbind(row: SystemSocialUserApi.SocialUser) {
function onUnbind(item: SocialBindItem) {
const socialUser = item.socialUser!;
confirm({
content: `确定解绑[${getDictLabel(DICT_TYPE.SYSTEM_SOCIAL_TYPE, row.type)}]平台的[${row.openid}]账号吗?`,
content: `确定解绑[${getDictLabel(DICT_TYPE.SYSTEM_SOCIAL_TYPE, socialUser.type)}]平台的[${socialUser.nickname || socialUser.openid}]账号吗?`,
}).then(async () => {
await socialUnbind({ type: row.type, openid: row.openid });
// 提示成功
await socialUnbind({ type: socialUser.type, openid: socialUser.openid });
message.success($t('ui.actionMessage.operationSuccess'));
await gridApi.reload();
await loadBindList();
});
}
/** 绑定账号(跳转授权页面) */
async function onBind(bind: any) {
async function onBind(bind: SocialBindItem) {
const type = bind.type;
if (type <= 0) {
return;
}
try {
// 计算 redirectUri
// tricky: type 需要先 encode 一次,否则钉钉回调会丢失。配合 getUrlValue() 使用
const redirectUri = `${location.origin}/profile?${encodeURIComponent(`type=${type}`)}`;
// 进行跳转
window.location.href = await socialAuthRedirect(type, redirectUri);
} catch (error) {
console.error('社交绑定处理失败:', error);
@@ -135,7 +89,6 @@ async function onBind(bind: any) {
/** 监听路由变化,处理社交绑定回调 */
async function bindSocial() {
// 社交绑定
const type = Number(getUrlValue('type'));
const code = route.query.code as string;
const state = route.query.state as string;
@@ -143,64 +96,136 @@ async function bindSocial() {
return;
}
await socialBind({ type, code, state });
// 提示成功
message.success('绑定成功');
emit('update:activeName', 'userSocial');
await gridApi.reload();
// 清理 URL 参数,避免刷新重复触发
await loadBindList();
window.history.replaceState({}, '', location.pathname);
}
/** 初始化 */
onMounted(() => {
bindSocial();
onMounted(async () => {
await loadBindList();
await bindSocial();
});
</script>
<template>
<div class="flex flex-col">
<Grid />
<div class="space-y-4 py-2">
<div
v-for="item in allBindList"
:key="item.type"
class="rounded-lg border border-solid"
:class="
item.socialUser
? 'border-blue-200 dark:border-blue-800'
: 'border-gray-200 dark:border-gray-700'
"
>
<!-- 主行图标 + 平台名 + 状态 + 操作 -->
<div class="flex items-center gap-4 px-4 py-3">
<!-- 平台图标 -->
<div class="flex h-10 w-10 shrink-0 items-center justify-center">
<Image
:src="item.img"
:width="36"
:height="36"
:alt="item.title"
:preview="false"
/>
</div>
<div class="pb-3">
<!-- 平台名称 + 状态 -->
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-black/85 dark:text-white/85">
{{ getDictLabel(DICT_TYPE.SYSTEM_SOCIAL_TYPE, item.type) }}
</span>
<Tag
v-if="item.socialUser"
color="blue"
:bordered="false"
class="!mr-0 !text-xs"
>
已绑定
</Tag>
<Tag v-else :bordered="false" class="!mr-0 !text-xs">未绑定</Tag>
</div>
<span class="text-xs text-black/45 dark:text-white/45">
<template v-if="!item.socialUser">
绑定后可使用该平台快速登录
</template>
<template v-else>
{{ item.socialUser.nickname || item.socialUser.openid }}
</template>
</span>
</div>
<!-- 操作按钮 -->
<div class="shrink-0">
<template v-if="item.socialUser">
<Button size="small" danger @click="onUnbind(item)">解绑</Button>
</template>
<template
v-else-if="
item.type === SystemUserSocialTypeEnum.WECHAT_MINI_APP.type
"
>
<Tooltip title="请在微信小程序「我的」页面中完成绑定">
<Button size="small" disabled>小程序端绑定</Button>
</Tooltip>
</template>
<template v-else>
<Button size="small" type="primary" ghost @click="onBind(item)">
绑定
</Button>
</template>
</div>
</div>
<!-- 已绑定:展示详细信息 -->
<div
class="grid grid-cols-1 gap-2 px-2 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 2xl:grid-cols-3"
v-if="item.socialUser"
class="border-t border-solid border-gray-100 bg-gray-50/50 px-4 py-3 dark:border-gray-700 dark:bg-white/[0.02]"
>
<Card v-for="item in allBindList" :key="item.type" class="!mb-2">
<div class="flex w-full items-center gap-4">
<Image
:src="item.img"
:width="40"
:height="40"
:alt="item.title"
:preview="false"
/>
<div class="flex flex-1 items-center justify-between">
<div class="flex flex-col">
<h4 class="mb-1 text-sm text-black/85 dark:text-white/85">
{{ getDictLabel(DICT_TYPE.SYSTEM_SOCIAL_TYPE, item.type) }}
</h4>
<span class="text-black/45 dark:text-white/45">
<template v-if="item.socialUser">
{{ item.socialUser?.nickname || item.socialUser?.openid }}
</template>
<template v-else>
绑定
{{ getDictLabel(DICT_TYPE.SYSTEM_SOCIAL_TYPE, item.type) }}
账号
</template>
</span>
</div>
<Button
:disabled="!!item.socialUser"
size="small"
type="link"
@click="onBind(item)"
>
{{ item.socialUser ? '已绑定' : '绑定' }}
</Button>
<div
class="grid grid-cols-1 gap-x-6 gap-y-2 text-xs sm:grid-cols-2 lg:grid-cols-3"
>
<!-- 头像 + 昵称 -->
<div class="flex items-center gap-2">
<span class="text-black/45 dark:text-white/45">昵称:</span>
<div class="flex items-center gap-1.5">
<Avatar
v-if="item.socialUser.avatar"
:src="item.socialUser.avatar"
:size="20"
/>
<span class="text-black/85 dark:text-white/85">
{{ item.socialUser.nickname || '-' }}
</span>
</div>
</div>
</Card>
<!-- OpenID -->
<div class="flex items-center gap-2">
<span class="shrink-0 text-black/45 dark:text-white/45">
标识:
</span>
<span
class="truncate text-black/85 dark:text-white/85"
:title="item.socialUser.openid"
>
{{ item.socialUser.openid }}
</span>
</div>
<!-- 绑定时间 -->
<div class="flex items-center gap-2">
<span class="shrink-0 text-black/45 dark:text-white/45">
绑定时间:
</span>
<span class="text-black/85 dark:text-white/85">
{{ formatDateTime(item.socialUser.createTime) || '-' }}
</span>
</div>
</div>
</div>
</div>
</div>

View File

@@ -249,7 +249,6 @@ export function useFormSchema(): VbenFormSchema[] {
componentProps: {
placeholder: '请输入自定义域名',
},
rules: 'required',
dependencies: {
triggerFields: ['storage'],
show: (formValues) => !!formValues.storage,

View File

@@ -96,7 +96,7 @@ const alarmImages = computed(() => {
</Descriptions.Item>
<Descriptions.Item
v-if="alarmImages.length > 0"
label="告警图"
label="告警图"
:span="3"
>
<div class="image-gallery">
@@ -105,9 +105,13 @@ const alarmImages = computed(() => {
v-for="(url, idx) in alarmImages"
:key="idx"
:src="url"
:alt="`告警图 ${idx + 1}`"
class="gallery-image"
:style="{ maxHeight: '360px', objectFit: 'contain' }"
:alt="`告警图 ${idx + 1}`"
:width="200"
:height="150"
:style="{
objectFit: 'cover',
borderRadius: '8px',
}"
/>
</Image.PreviewGroup>
</div>
@@ -142,17 +146,19 @@ const alarmImages = computed(() => {
/>
处理图片{{ resultImages.length }}
</div>
<div class="image-gallery image-gallery--grid">
<div class="image-gallery">
<Image.PreviewGroup>
<Image
v-for="(url, idx) in resultImages"
:key="idx"
:src="url"
:alt="`处理结果图片 ${idx + 1}`"
class="gallery-image-thumb"
:width="160"
:height="120"
:style="{ objectFit: 'cover' }"
:style="{
objectFit: 'cover',
borderRadius: '8px',
}"
/>
</Image.PreviewGroup>
</div>
@@ -190,33 +196,26 @@ const alarmImages = computed(() => {
/* 图片画廊 */
.image-gallery {
overflow: hidden;
border-radius: 8px;
}
.image-gallery--grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.gallery-image {
.image-gallery :deep(.ant-image) {
overflow: hidden;
border-radius: 8px;
}
.gallery-image-thumb {
overflow: hidden;
cursor: pointer;
border: 1px solid #f0f0f0;
border-radius: 8px;
transition: transform 0.2s;
}
.gallery-image-thumb:hover {
.image-gallery :deep(.ant-image:hover) {
transform: scale(1.03);
}
.image-gallery :deep(.ant-image-img) {
border-radius: 8px;
}
/* 处理结果 */
.result-content {
font-size: 14px;

View File

@@ -55,6 +55,7 @@ import {
} from '../data';
import AssignForm from '../modules/assign-form.vue';
import CancelForm from '../modules/cancel-form.vue';
import SecurityAssignForm from '../modules/security-assign-form.vue';
import UpgradePriorityForm from '../modules/upgrade-priority-form.vue';
defineOptions({ name: 'WorkOrderDetail' });
@@ -286,6 +287,10 @@ const [AssignFormModal, assignFormModalApi] = useVbenModal({
connectedComponent: AssignForm,
destroyOnClose: true,
});
const [SecurityAssignFormModal, securityAssignFormModalApi] = useVbenModal({
connectedComponent: SecurityAssignForm,
destroyOnClose: true,
});
const [UpgradePriorityFormModal, upgradePriorityFormModalApi] = useVbenModal({
connectedComponent: UpgradePriorityForm,
destroyOnClose: true,
@@ -317,7 +322,7 @@ const orderImages = computed(() => {
const ext = order.value.extInfo;
if (!ext) return [];
const images: string[] = [];
// 安保告警图已在事件信息卡片中展示,此处不再重复
// 安保告警图已在事件信息卡片中展示,此处不再重复
if (
!isSecurityOrder.value &&
(ext as OpsOrderCenterApi.SecurityExtInfo).imageUrl
@@ -489,9 +494,23 @@ async function handleBack() {
}, 100);
}
function handleAssign() {
assignFormModalApi
.setData({ orderId: order.value.id, orderCode: order.value.orderCode })
.open();
if (isSecurityOrder.value) {
securityAssignFormModalApi
.setData({
orderId: order.value.id,
orderCode: order.value.orderCode,
location: order.value.location,
description: order.value.description,
})
.open();
} else {
assignFormModalApi
.setData({
orderId: order.value.id,
orderCode: order.value.orderCode,
})
.open();
}
}
function handleUpgrade() {
upgradePriorityFormModalApi
@@ -588,6 +607,7 @@ onUnmounted(stopPolling);
<template>
<Page>
<AssignFormModal @success="handleRefresh" />
<SecurityAssignFormModal @success="handleRefresh" />
<UpgradePriorityFormModal @success="handleRefresh" />
<CancelFormModal @success="handleRefresh" />

View File

@@ -36,6 +36,7 @@ import {
import AssignForm from './modules/assign-form.vue';
import CancelForm from './modules/cancel-form.vue';
import CardView from './modules/card-view.vue';
import SecurityAssignForm from './modules/security-assign-form.vue';
import StatsBar from './modules/stats-bar.vue';
import UpgradePriorityForm from './modules/upgrade-priority-form.vue';
@@ -64,6 +65,10 @@ const [AssignFormModal, assignFormModalApi] = useVbenModal({
connectedComponent: AssignForm,
destroyOnClose: true,
});
const [SecurityAssignFormModal, securityAssignFormModalApi] = useVbenModal({
connectedComponent: SecurityAssignForm,
destroyOnClose: true,
});
const [UpgradePriorityFormModal, upgradePriorityFormModalApi] = useVbenModal({
connectedComponent: UpgradePriorityForm,
@@ -143,9 +148,20 @@ function handleDetail(id: number) {
/** 打开派单表单 */
function handleAssign(row: OpsOrderCenterApi.OrderItem) {
assignFormModalApi
.setData({ orderId: row.id, orderCode: row.orderCode })
.open();
if (row.orderType === 'SECURITY') {
securityAssignFormModalApi
.setData({
orderId: row.id,
orderCode: row.orderCode,
location: row.location,
description: row.title,
})
.open();
} else {
assignFormModalApi
.setData({ orderId: row.id, orderCode: row.orderCode })
.open();
}
}
/** 升级优先级 */
@@ -300,6 +316,7 @@ onActivated(() => {
<Page auto-content-height>
<!-- 模态框 -->
<AssignFormModal @success="handleRefresh" />
<SecurityAssignFormModal @success="handleRefresh" />
<UpgradePriorityFormModal @success="handleRefresh" />
<CancelFormModal @success="handleRefresh" />

View File

@@ -4,120 +4,180 @@ import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { Alert, message } from 'ant-design-vue';
import { Alert, Input, message } from 'ant-design-vue';
import { useVbenForm, z } from '#/adapter/form';
import { cancelOrder } from '#/api/ops/order-center';
defineOptions({ name: 'CancelOrderForm' });
const emit = defineEmits<{ success: [] }>();
interface ModalData {
orderId: number;
orderCode: string;
title: string;
}
const modalData = ref<ModalData>({
orderId: 0,
orderCode: '',
title: '',
});
const reason = ref('');
const loading = ref(false);
const [Modal, modalApi] = useVbenModal({
onOpenChange: (isOpen) => {
if (isOpen) {
const data = modalApi.getData<{
orderCode: string;
orderId: number;
title: string;
}>();
const data = modalApi.getData<ModalData>();
if (data) {
orderId.value = data.orderId;
orderCode.value = data.orderCode;
orderTitle.value = data.title;
modalData.value = data;
}
reason.value = '';
}
},
onConfirm: handleSubmit,
});
const orderId = ref<number>();
const orderCode = ref<string>('');
const orderTitle = ref<string>('');
const loading = ref(false);
const [Form, formApi] = useVbenForm({
schema: [
{
fieldName: 'reason',
label: '取消原因',
component: 'Textarea',
componentProps: {
placeholder: '请输入取消工单的原因',
rows: 4,
maxLength: 200,
showCount: true,
},
rules: z
.string()
.min(2, '原因至少2个字符')
.max(200, '原因不能超过200字符'),
},
],
showDefaultActions: false,
});
/** 提交表单 */
async function handleSubmit() {
const { valid, values } = await formApi.validate();
if (!valid) return;
const val = reason.value.trim();
if (val.length < 2) {
message.warning('取消原因至少2个字符');
return;
}
if (val.length > 200) {
message.warning('取消原因不能超过200字符');
return;
}
loading.value = true;
modalApi.setState({ confirmLoading: true });
try {
await cancelOrder({
id: orderId.value!,
reason: values.reason,
id: modalData.value.orderId,
reason: val,
});
message.success('工单已取消');
modalApi.close();
emit('success');
} finally {
loading.value = false;
modalApi.setState({ confirmLoading: false });
}
}
</script>
<template>
<Modal title="取消工单" class="w-[480px]">
<div class="cancel-form">
<!-- 警告提示 -->
<Alert
type="error"
show-icon
class="mb-4"
message="取消后工单将无法恢复,请确认操作"
>
<template #icon>
<IconifyIcon icon="lucide:alert-circle" class="text-red-500" />
</template>
</Alert>
<!-- 警告提示 -->
<Alert
type="error"
show-icon
class="cf-alert"
message="取消后工单将无法恢复,请确认操作"
>
<template #icon>
<IconifyIcon icon="lucide:alert-circle" class="text-red-500" />
</template>
</Alert>
<!-- 工单信息 -->
<div class="mb-4 rounded-lg bg-gray-50 p-4 dark:bg-gray-800">
<div class="mb-2 text-sm text-gray-500">
工单编号<span
class="font-medium text-gray-700 dark:text-gray-300"
>{{ orderCode }}</span
>
</div>
<div class="text-sm text-gray-500">
工单标题<span
class="font-medium text-gray-700 dark:text-gray-300"
>{{ orderTitle }}</span
>
</div>
<!-- 工单信息 -->
<div class="cf-order-info">
<div class="cf-info-row">
<IconifyIcon
icon="solar:document-text-bold-duotone"
class="cf-info-icon"
/>
<span class="cf-info-label">工单编号</span>
<span class="cf-info-value">{{ modalData.orderCode }}</span>
</div>
<div v-if="modalData.title" class="cf-info-row">
<IconifyIcon icon="solar:notes-bold-duotone" class="cf-info-icon" />
<span class="cf-info-label">工单标题</span>
<span class="cf-info-value cf-info-desc">{{ modalData.title }}</span>
</div>
</div>
<!-- 表单 -->
<Form />
<!-- 取消原因 -->
<div class="cf-section">
<div class="cf-section-title">取消原因</div>
<Input.TextArea
v-model:value="reason"
placeholder="请输入取消工单的原因"
:rows="4"
:maxlength="200"
show-count
/>
</div>
</Modal>
</template>
<style scoped>
.cancel-form {
:deep(.ant-form-item) {
margin-bottom: 0;
}
.cf-alert {
margin-bottom: 12px;
}
.cf-order-info {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
margin-bottom: 12px;
background: #fafafa;
border-radius: 8px;
}
.cf-info-row {
display: flex;
gap: 6px;
align-items: flex-start;
font-size: 13px;
line-height: 20px;
}
.cf-info-icon {
flex-shrink: 0;
margin-top: 2px;
color: #8c8c8c;
}
.cf-info-label {
flex-shrink: 0;
color: #8c8c8c;
}
.cf-info-value {
flex: 1;
min-width: 0;
color: #333;
word-break: break-all;
}
.cf-info-desc {
display: -webkit-box;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.cf-section {
margin-bottom: 12px;
}
.cf-section-title {
margin-bottom: 8px;
font-size: 13px;
font-weight: 500;
}
/* dark mode */
html.dark .cf-order-info {
background: rgb(255 255 255 / 6%);
}
html.dark .cf-info-value {
color: rgb(255 255 255 / 85%);
}
</style>

View File

@@ -0,0 +1,354 @@
<script setup lang="ts">
import type { SystemUserApi } from '#/api/system/user';
import { onUnmounted, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import {
Avatar,
Empty,
Input,
message,
Pagination,
Spin,
} from 'ant-design-vue';
import { assignOrder } from '#/api/ops/order-center';
import { getUserPage } from '#/api/system/user';
defineOptions({ name: 'SecurityAssignForm' });
const emit = defineEmits<{ success: [] }>();
interface ModalData {
orderId: number;
orderCode: string;
location?: string;
description?: string;
}
const modalData = ref<ModalData>({
orderId: 0,
orderCode: '',
});
const loading = ref(false);
const userLoading = ref(false);
const userList = ref<SystemUserApi.User[]>([]);
const total = ref(0);
const currentPage = ref(1);
const pageSize = ref(20);
const selectedUserId = ref<number>();
const searchValue = ref('');
const remark = ref('');
const [Modal, modalApi] = useVbenModal({
onOpenChange: async (isOpen) => {
if (isOpen) {
const data = modalApi.getData<ModalData>();
if (data) {
modalData.value = data;
}
selectedUserId.value = undefined;
searchValue.value = '';
remark.value = '';
currentPage.value = 1;
await loadUsers();
}
},
onConfirm: handleSubmit,
});
/** 加载用户分页 */
async function loadUsers() {
userLoading.value = true;
try {
const res = await getUserPage({
pageNo: currentPage.value,
pageSize: pageSize.value,
nickname: searchValue.value.trim() || undefined,
});
userList.value = res?.list || [];
total.value = res?.total || 0;
} catch {
userList.value = [];
total.value = 0;
} finally {
userLoading.value = false;
}
}
/** 搜索防抖 */
let searchTimer: ReturnType<typeof setTimeout>;
watch(searchValue, () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
currentPage.value = 1;
loadUsers();
}, 300);
});
/** 翻页 */
function handlePageChange(page: number) {
currentPage.value = page;
loadUsers();
}
/** 选择用户 */
function selectUser(user: SystemUserApi.User) {
selectedUserId.value = user.id;
}
onUnmounted(() => {
clearTimeout(searchTimer);
});
/** 提交 */
async function handleSubmit() {
if (!selectedUserId.value) {
message.warning('请选择执行人');
return;
}
loading.value = true;
modalApi.setState({ confirmLoading: true });
try {
await assignOrder({
orderId: modalData.value.orderId,
assigneeId: selectedUserId.value,
remark: remark.value.trim() || undefined,
});
message.success('派单成功');
modalApi.close();
emit('success');
} finally {
loading.value = false;
modalApi.setState({ confirmLoading: false });
}
}
</script>
<template>
<Modal title="分配安保执行人" class="w-[580px]">
<!-- 工单信息 -->
<div class="sa-order-info">
<div class="sa-info-row">
<IconifyIcon
icon="solar:document-text-bold-duotone"
class="sa-info-icon"
/>
<span class="sa-info-label">工单编号</span>
<span class="sa-info-value">{{ modalData.orderCode }}</span>
</div>
<div v-if="modalData.location" class="sa-info-row">
<IconifyIcon icon="solar:map-point-bold-duotone" class="sa-info-icon" />
<span class="sa-info-label">位置</span>
<span class="sa-info-value">{{ modalData.location }}</span>
</div>
<div v-if="modalData.description" class="sa-info-row">
<IconifyIcon icon="solar:notes-bold-duotone" class="sa-info-icon" />
<span class="sa-info-label">描述</span>
<span class="sa-info-value sa-info-desc">{{
modalData.description
}}</span>
</div>
</div>
<!-- 人员选择 -->
<div class="sa-section">
<div class="sa-section-title">选择执行人</div>
<Input
v-model:value="searchValue"
placeholder="搜索人员姓名"
allow-clear
class="mb-2"
>
<template #prefix>
<IconifyIcon icon="lucide:search" class="size-4 text-gray-400" />
</template>
</Input>
<Spin :spinning="userLoading">
<div v-if="userList.length > 0" class="sa-list">
<div
v-for="user in userList"
:key="user.id"
class="sa-item"
:class="{ 'sa-item--active': selectedUserId === user.id }"
@click="selectUser(user)"
>
<Avatar :size="28" class="sa-avatar">
{{ user.nickname?.charAt(0) || '?' }}
</Avatar>
<span class="sa-name">{{ user.nickname }}</span>
<IconifyIcon
v-if="selectedUserId === user.id"
icon="lucide:check"
class="sa-check"
/>
</div>
</div>
<Empty
v-else-if="!userLoading"
:image="Empty.PRESENTED_IMAGE_SIMPLE"
description="暂无匹配人员"
/>
</Spin>
<div v-if="total > pageSize" class="sa-pagination">
<Pagination
:current="currentPage"
:page-size="pageSize"
:total="total"
size="small"
simple
@change="handlePageChange"
/>
</div>
</div>
<!-- 派单备注 -->
<div class="sa-section">
<div class="sa-section-title">派单备注</div>
<Input.TextArea
v-model:value="remark"
placeholder="请输入派单备注(选填)"
:rows="3"
/>
</div>
</Modal>
</template>
<style scoped>
.sa-order-info {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
margin-bottom: 12px;
background: #fafafa;
border-radius: 8px;
}
.sa-info-row {
display: flex;
gap: 6px;
align-items: flex-start;
font-size: 13px;
line-height: 20px;
}
.sa-info-icon {
flex-shrink: 0;
margin-top: 2px;
color: #8c8c8c;
}
.sa-info-label {
flex-shrink: 0;
color: #8c8c8c;
}
.sa-info-value {
flex: 1;
min-width: 0;
color: #333;
word-break: break-all;
}
.sa-info-desc {
display: -webkit-box;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.sa-section {
margin-bottom: 12px;
}
.sa-section-title {
margin-bottom: 8px;
font-size: 13px;
font-weight: 500;
}
.sa-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px;
max-height: 240px;
overflow-y: auto;
}
.sa-item {
display: flex;
gap: 8px;
align-items: center;
padding: 6px 10px;
cursor: pointer;
border: 1px solid transparent;
border-radius: 6px;
transition: all 0.15s;
}
.sa-item:hover {
background: #f5f5f5;
}
.sa-item--active {
font-weight: 600;
color: #1677ff;
background: #e6f4ff;
border-color: #91caff;
}
.sa-avatar {
flex-shrink: 0;
font-size: 12px;
background: #1677ff;
}
.sa-item--active .sa-avatar {
background: #0958d9;
}
.sa-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
white-space: nowrap;
}
.sa-check {
flex-shrink: 0;
color: #1677ff;
}
.sa-pagination {
display: flex;
justify-content: flex-end;
padding-top: 8px;
}
/* dark mode */
html.dark .sa-order-info {
background: rgb(255 255 255 / 6%);
}
html.dark .sa-info-value {
color: rgb(255 255 255 / 85%);
}
html.dark .sa-item:hover {
background: rgb(255 255 255 / 8%);
}
html.dark .sa-item--active {
color: #69b1ff;
background: rgb(22 119 255 / 15%);
border-color: rgb(22 119 255 / 40%);
}
</style>

View File

@@ -4,60 +4,41 @@ import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import { Alert, message } from 'ant-design-vue';
import { Alert, Input, message } from 'ant-design-vue';
import { useVbenForm, z } from '#/adapter/form';
import { upgradePriority } from '#/api/ops/cleaning';
defineOptions({ name: 'UpgradePriorityForm' });
const emit = defineEmits<{ success: [] }>();
interface ModalData {
orderId: number;
orderCode: string;
currentPriority: number;
}
const modalData = ref<ModalData>({
orderId: 0,
orderCode: '',
currentPriority: 2,
});
const reason = ref('');
const loading = ref(false);
const [Modal, modalApi] = useVbenModal({
onOpenChange: (isOpen) => {
if (isOpen) {
const data = modalApi.getData<{
currentPriority: number;
orderCode: string;
orderId: number;
}>();
const data = modalApi.getData<ModalData>();
if (data) {
orderId.value = data.orderId;
orderCode.value = data.orderCode;
currentPriority.value = data.currentPriority;
modalData.value = data;
}
reason.value = '';
}
},
onConfirm: handleSubmit,
});
const orderId = ref<number>();
const orderCode = ref<string>('');
const currentPriority = ref<number>(2);
const loading = ref(false);
const [Form, formApi] = useVbenForm({
schema: [
{
fieldName: 'reason',
label: '升级原因',
component: 'Textarea',
componentProps: {
placeholder:
'请输入升级为P0紧急工单的原因例如领导临时检查、VIP客户投诉等',
rows: 4,
maxLength: 200,
showCount: true,
},
rules: z
.string()
.min(5, '原因至少5个字符')
.max(200, '原因不能超过200字符'),
},
],
showDefaultActions: false,
});
/** 获取优先级文本 */
function getPriorityText(priority: number) {
const map: Record<number, string> = {
@@ -70,67 +51,159 @@ function getPriorityText(priority: number) {
/** 提交表单 */
async function handleSubmit() {
const { valid, values } = await formApi.validate();
if (!valid || !values) return;
const val = reason.value.trim();
if (val.length < 5) {
message.warning('升级原因至少5个字符');
return;
}
if (val.length > 200) {
message.warning('升级原因不能超过200字符');
return;
}
loading.value = true;
modalApi.setState({ confirmLoading: true });
try {
await upgradePriority({
orderId: orderId.value!,
reason: values.reason,
orderId: modalData.value.orderId,
reason: val,
});
message.success('已升级为P0紧急工单');
modalApi.close();
emit('success');
} finally {
loading.value = false;
modalApi.setState({ confirmLoading: false });
}
}
</script>
<template>
<Modal title="升级优先级" class="w-[500px]">
<div class="upgrade-form">
<!-- 警告提示 -->
<Alert
type="warning"
show-icon
class="mb-4"
message="升级为P0紧急工单后该工单将插队优先处理"
>
<template #icon>
<IconifyIcon icon="lucide:zap" class="text-orange-500" />
</template>
</Alert>
<!-- 警告提示 -->
<Alert
type="warning"
show-icon
class="uf-alert"
message="升级为P0紧急工单后该工单将优先处理"
>
<template #icon>
<IconifyIcon icon="lucide:zap" class="text-orange-500" />
</template>
</Alert>
<!-- 工单信息 -->
<div class="mb-4 rounded-lg bg-gray-50 p-4 dark:bg-gray-800">
<div class="mb-2 flex items-center justify-between">
<span class="text-sm text-gray-500">工单编号</span>
<span class="font-medium">{{ orderCode }}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-500">当前优先级</span>
<div class="flex items-center gap-2">
<span class="text-gray-600 dark:text-gray-300">
{{ getPriorityText(currentPriority) }}
</span>
<IconifyIcon icon="lucide:arrow-right" class="text-gray-400" />
<span class="font-bold text-red-500">P0 (紧急)</span>
</div>
</div>
<!-- 工单信息 -->
<div class="uf-order-info">
<div class="uf-info-row">
<IconifyIcon
icon="solar:document-text-bold-duotone"
class="uf-info-icon"
/>
<span class="uf-info-label">工单编号</span>
<span class="uf-info-value">{{ modalData.orderCode }}</span>
</div>
<div class="uf-info-row">
<IconifyIcon icon="solar:ranking-bold-duotone" class="uf-info-icon" />
<span class="uf-info-label">优先级变更</span>
<span class="uf-info-value uf-priority">
<span class="uf-priority-from">
{{ getPriorityText(modalData.currentPriority) }}
</span>
<IconifyIcon icon="lucide:arrow-right" class="uf-arrow" />
<span class="uf-priority-to">P0 (紧急)</span>
</span>
</div>
</div>
<!-- 表单 -->
<Form />
<!-- 升级原因 -->
<div class="uf-section">
<div class="uf-section-title">升级原因</div>
<Input.TextArea
v-model:value="reason"
placeholder="请输入升级为P0紧急工单的原因例如领导临时检查、VIP客户投诉等"
:rows="4"
:maxlength="200"
show-count
/>
</div>
</Modal>
</template>
<style scoped>
.upgrade-form {
:deep(.ant-form-item) {
margin-bottom: 0;
}
.uf-alert {
margin-bottom: 12px;
}
.uf-order-info {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
margin-bottom: 12px;
background: #fafafa;
border-radius: 8px;
}
.uf-info-row {
display: flex;
gap: 6px;
align-items: center;
font-size: 13px;
line-height: 20px;
}
.uf-info-icon {
flex-shrink: 0;
color: #8c8c8c;
}
.uf-info-label {
flex-shrink: 0;
color: #8c8c8c;
}
.uf-info-value {
flex: 1;
min-width: 0;
color: #333;
word-break: break-all;
}
.uf-priority {
display: flex;
gap: 6px;
align-items: center;
}
.uf-priority-from {
color: #8c8c8c;
}
.uf-arrow {
color: #bfbfbf;
}
.uf-priority-to {
font-weight: 600;
color: #ff4d4f;
}
.uf-section {
margin-bottom: 12px;
}
.uf-section-title {
margin-bottom: 8px;
font-size: 13px;
font-weight: 500;
}
/* dark mode */
html.dark .uf-order-info {
background: rgb(255 255 255 / 6%);
}
html.dark .uf-info-value {
color: rgb(255 255 255 / 85%);
}
</style>

View File

@@ -56,4 +56,10 @@ export const SystemUserSocialTypeEnum = {
source: 'wechat_enterprise',
img: 'https://s1.ax1x.com/2022/05/22/OzMrzn.png',
},
WECHAT_MINI_APP: {
title: '微信小程序',
type: 34,
source: 'wechat_mini_app',
img: 'https://res.wx.qq.com/a/wx_fed/assets/res/NTI4MWU5.ico',
},
};