Files
tuanwei-admin/src/views/oa/task/components/task-info.vue
T
weicw1996 6b967b6b69 修正类型导入写法,补上 pnpm 锁文件
- ItemType / ColumnItem / EleProTableDone 等纯类型改用 import type,
  避免打包器把类型当运行时依赖处理
- 提交 pnpm-lock.yaml 与 pnpm-workspace.yaml

注:仓库里原有的 package-lock.json 与 yarn.lock 未动,三份锁文件并存。
2026-09-18 23:30:55 +08:00

775 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 用户编辑弹窗 -->
<template>
<a-drawer
:width="width"
:visible="visible"
:confirm-loading="loading"
v-if="detail && data"
:title="`工单详情(${detail.taskId})${data.name}`"
:maxable="true"
:body-style="{ paddingBottom: '8px', backgroundColor: '#f3f3f3' }"
@update:visible="updateVisible"
@close="onClose"
:footer="null"
>
<template #extra>
<compress-outlined v-if="width === '100%'" @click="handleMax" />
<expand-outlined v-else @click="handleMax" />
</template>
<a-card class="task-card" :bordered="false">
<a-steps
:current="active"
direction="horizontal"
:responsive="styleResponsive"
>
<template v-for="item in taskProgressDict">
<a-step :title="item.label" />
</template>
</a-steps>
</a-card>
<a-spin :spinning="loading" v-if="appId > 0">
<a-collapse class="task-card" v-model:activeKey="activeKey" :bordered="false">
<a-collapse-panel key="1" header="应用详情">
<a-card
:bordered="false"
:body-style="{ padding: '16px' }"
>
<div class="content">
<div class="app-item-list">
<div class="ele-cell">
<div class="ele-cell-content">
<div class="ele-cell-desc">AppID(应用ID)</div>
<div class="ele-cell-title">{{ appInfo.appId }}</div>
</div>
</div>
<a-divider />
<div class="ele-cell">
<div class="ele-cell-content">
<div class="ele-cell-desc">小程序名称</div>
<div class="ele-cell-title">
{{ appInfo.appName }}
</div>
</div>
</div>
<a-divider />
<div class="ele-cell">
<div class="ele-cell-content">
<div class="ele-cell-desc">小程序描述</div>
<div class="ele-cell-title">{{ appInfo.comments }}</div>
</div>
</div>
<a-divider />
<div class="ele-cell">
<div class="ele-cell-content">
<div class="ele-cell-desc">小程序码</div>
<div class="ele-cell-title">
<a-image
:height="120"
:width="120"
:preview="false"
:src="appInfo.appQrcode"
fallback="https://file.wsdns.cn/20230218/550e610d43334dd2a7f66d5b20bd58eb.svg"
/>
</div>
</div>
</div>
<a-divider />
<div class="ele-cell">
<div class="ele-cell-content">
<div class="ele-cell-desc">应用图标</div>
<div class="ele-cell-title">
<a-image
:height="70"
:width="70"
:preview="false"
:src="appInfo.appIcon"
fallback="https://file.wsdns.cn/20230218/550e610d43334dd2a7f66d5b20bd58eb.svg"
/>
</div>
</div>
</div>
<div class="ele-cell">
<div class="ele-cell-content">
<div class="ele-cell-desc">状态</div>
<div class="ele-cell-title">{{
appInfo.status === 1 ? '开发中' : '已上线'
}}</div>
</div>
</div>
</div>
</div>
</a-card>
</a-collapse-panel>
<a-collapse-panel key="2" header="开发资料">
<a-card
:bordered="false"
:body-style="{ padding: '16px' }"
>
<byte-md-viewer :value="appInfo.requirement" :plugins="plugins" />
</a-card>
</a-collapse-panel>
</a-collapse>
</a-spin>
<a-spin :spinning="loading" v-if="detail">
<!-- 回复列表 -->
<a-card class="task-card replay-bg"
:bordered="false"
v-for="(item, index) in record"
:key="index">
<a-page-header
:title="item.nickname"
:sub-title="`${item.createTime}`"
:avatar="{ src: item.avatar ? item.avatar : 'https://file.gxwebsoft.com/20230217/c8a5c699b3174866a36dd6d378a09bb9.jpg' }"
>
<template #extra>
<a-button key="2" href="#bottom" @click="onReply(item)">回复</a-button>
<template v-for="(role,index) in loginUser.roles" :key="index">
<a-button key="1" v-if="role.roleCode === 'admin'" @click="remove(item)">删除</a-button>
</template>
</template>
<div class="content">
<template v-if="item.content">
<byte-md-viewer :value="item.content" :plugins="plugins" />
</template>
<!-- 附件列表 -->
<a-space v-if="item.files && item.files != '[]'">
<template v-for="file in JSON.parse(item.files)">
<a-button v-if="file.name && file.name != 'image'" :href="file.url">{{ file.name }}</a-button>
<a-image v-else :height="120" :width="120" :src="file.url" />
</template>
</a-space>
<!-- 二级回复 -->
<template v-if="item.children">
<a-card class="task-card replay-bg"
v-for="(item, index) in item.children"
:key="index">
<a-page-header
:title="item.nickname"
:sub-title="`${item.createTime}`"
:avatar="{ src: item.avatar ? item.avatar : 'https://file.gxwebsoft.com/20230217/c8a5c699b3174866a36dd6d378a09bb9.jpg' }"
>
<template #extra>
<template v-for="(role,index) in loginUser.roles" :key="index">
<a-button key="1" v-if="role.roleCode === 'admin'" @click="remove(item)">删除</a-button>
</template>
</template>
<div class="content">
<template v-if="item.content">
<byte-md-viewer :value="item.content" :plugins="plugins" />
</template>
<template v-if="item.children">
{{ item.children }}
</template>
</div>
<!-- 附件列表 -->
<a-space v-if="item.files && item.files != '[]'">
<template v-for="file in JSON.parse(item.files)">
<a-button v-if="file.name && file.name != 'image'" :href="file.url">{{ file.name }}</a-button>
<a-image v-else :height="120" :width="120" :src="file.url" />
</template>
</a-space>
</a-page-header>
</a-card>
</template>
</div>
</a-page-header>
</a-card>
<!-- 操作按钮 -->
<div class="task-btn" v-if="detail.status === 0">
<a-space>
<a-button type="primary" ghost size="large" @click="complete">
已解决
</a-button>
<a-button
type="primary"
danger
ghost
size="large"
href="#bottom"
@click="showEdit"
>
我要回复
</a-button>
<a-button
type="primary"
danger
ghost
size="large"
href="#bottom"
@click="showUsers"
>
移交工程师
</a-button>
<a-button type="primary" ghost size="large" @click="completedTask">
已完结
</a-button>
<a-button type="primary" ghost size="large" @click="closeTask">
关单
</a-button>
</a-space>
</div>
<div class="task-btn" v-if="detail.status === 1">
<a-button type="primary" ghost size="large" @click="openTask">
开启工单
</a-button>
</div>
</a-spin>
<!-- 回复表单 -->
<div id="bottom">
<a-card :title="replyName ? `回复${replyName}` : `处理结果`" :bordered="false" v-if="openEdit">
<a-space direction="vertical" style="width: 100%;">
<!-- 编辑器 -->
<byte-md-editor
v-model:value="content"
placeholder="请输入您的回复内容,图片请直接粘贴"
:locale="zh_Hans"
:plugins="plugins"
height="300px"
:editorConfig="{ lineNumbers: true }"
contenteditable="true"
@paste="onPaste"
/>
<!-- <ele-image-upload-->
<!-- v-model:value="files"-->
<!-- :limit="9"-->
<!-- :drag="true"-->
<!-- :multiple="true"-->
<!-- :upload-handler="uploadHandler"-->
<!-- @upload="onUpload"-->
<!-- />-->
<a-upload
v-model:file-list="fileList"
class="upload-list-inline"
list-type="picture"
:before-upload="beforeUpload"
>
<a-button>
<UploadOutlined />
上传附件
</a-button>
</a-upload>
<a-button type="primary" style="margin-top: 20px" @click="save">提交</a-button>
</a-space>
</a-card>
</div>
<!-- 成员管理 -->
<div id="users">
<a-card title="移交给" :bordered="false" v-if="openUsers">
<a-space style="margin-bottom: 15px">
<SelectStaff v-model:value="commander" @done="onSelect" />
<a-button
type="primary"
class="ele-btn-icon"
@click="onUpdateTask"
>
<template #icon>
<plus-outlined />
</template>
<span>确定</span>
</a-button>
</a-space>
</a-card>
</div>
</a-drawer>
</template>
<script lang="ts" setup>
import "bytemd/dist/index.min.css";
import { createVNode, reactive, ref, watch, nextTick, computed } from "vue";
import "github-markdown-css/github-markdown-light.css";
// // 链接、删除线、复选框、表格等的插件
import gfm from "@bytemd/plugin-gfm";
// // 插件的中文语言文件
import zh_HansGfm from "@bytemd/plugin-gfm/locales/zh_Hans.json";
// 中文语言文件
import zh_Hans from "bytemd/locales/zh_Hans.json";
import "bytemd/dist/index.min.css";
import highlight from "@bytemd/plugin-highlight-ssr";
import "highlight.js/styles/default.css";
import { Form, message, Modal, UploadProps } from "ant-design-vue";
import { storeToRefs } from "pinia";
import { useThemeStore } from "@/store/modules/theme";
import { uploadFile } from "@/api/system/file";
import type { Task } from "@/api/oa/task/model";
import { updateTask, getTask } from "@/api/oa/task";
import { RuleObject } from "ant-design-vue/es/form";
import { FILE_SERVER } from "@/config/setting";
import { getDictionaryOptions, isImage, openUrl } from "@/utils/common";
import { TaskRecord } from "@/api/oa/task-record/model";
import { App } from "@/api/app/model"
import type { ItemType } from "ele-admin-pro/es/ele-image-upload/types";
import { addTaskRecord, listTaskRecord, removeTaskRecord } from "@/api/oa/task-record";
import SelectStaff from '@/components/SelectStaff/index.vue';
import { UploadOutlined } from '@ant-design/icons-vue';
// 当前用户信息
import { useUserStore } from '@/store/modules/user';
import { Notice } from "@/api/oa/notice/model";
import { messageLoading, toTreeData } from "ele-admin-pro";
import { removeNotice } from "@/api/oa/notice";
import { Menu } from "@/api/system/menu/model";
import { getApp, getAppSecret } from "@/api/app";
import {
PlusOutlined,
EyeOutlined,
EyeInvisibleOutlined,
UserOutlined,
CompressOutlined,
ExpandOutlined,
ExclamationCircleOutlined
} from '@ant-design/icons-vue';
const props = defineProps<{
// 弹窗是否打开
visible: boolean;
// 修改回显的数据
data?: Task | null;
}>();
const emit = defineEmits<{
(e: "done"): void;
(e: "update:visible", visible: boolean): void;
}>();
/* 更新visible */
const updateVisible = (value: boolean) => {
emit("update:visible", value);
};
// 插件
const plugins = ref([
gfm({
locale: zh_HansGfm
}),
highlight()
]);
// 表单数据
const form = reactive<TaskRecord>({
taskRecordId: undefined,
taskId: 0,
content: '',
files: "",
userId: 0
});
const useForm = Form.useForm;
// 表单验证规则
const rules = reactive({
content: [
{
required: true,
type: "string",
message: "请输入工单内容",
trigger: "blur",
validator: async (_rule: RuleObject, value: string) => {
if (content.value == "") {
return Promise.reject("请输入文字内容");
}
return Promise.resolve();
}
}
]
});
// 是否开启响应式布局
const themeStore = useThemeStore();
const { styleResponsive } = storeToRefs(themeStore);
const userStore = useUserStore();
const loginUser = computed(() => userStore.info ?? {});
// 请求状态
const loading = ref(true);
const width = ref('75%');
const detail = ref<Task | null>(null);
const appId = ref<number>(0);
const appInfo = ref<App | null>({});
const taskId = ref(0);
const parentId = ref<any>(0);
const replyName = ref<string>();
const activeKey = ref([]);
const showAppSecret = ref(false);
const taskProgressDict = getDictionaryOptions("taskProgress");
const record = ref<TaskRecord[] | undefined>([]);
const content = ref('');
const files = ref<ItemType[]>([]);
const fileList = ref<ItemType[]>([]);
const openEdit = ref(false);
const openUsers = ref(false);
const showAddUserForm = ref(false);
const developerId = ref();
const commander = ref<any>();
// 选中步骤
const active = ref(0);
const { resetFields, validate, validateInfos } = useForm(form, rules);
/* 图片上传事件 */
const uploadHandler = (file: File) => {
const item: ItemType = {
file,
uid: (file as any).uid,
name: file.name
};
if (!file.type.startsWith("image")) {
message.error("只能选择图片");
return;
}
if (file.size / 1024 / 1024 > 2) {
message.error("大小不能超过 2MB");
return;
}
onUpload(item);
};
// 文件上传事件
const beforeUpload = (file: File) => {
const item: ItemType = {
file,
uid: (file as any).uid,
name: file.name
}
if (!file.type.startsWith("image")) {
if (file.size / 1024 / 1024 > 100) {
message.error("大小不能超过 100MB");
return;
}
}
onUpload(item);
return false;
};
const onUpload = (d: ItemType) => {
uploadFile(<File>d.file)
.then((result) => {
files.value.push({
uid: result.id,
url: FILE_SERVER + result.path,
name: isImage(result.path) ? 'image' : result.name,
status: "done"
});
console.log(files.value);
message.success("上传成功");
})
.catch((e) => {
message.error(e.message);
});
};
const complete = () => {
Modal.confirm({
title: "提示",
content: "确定问题已解决?",
icon: createVNode(ExclamationCircleOutlined),
maskClosable: true,
onOk: () => {
updateTask({ taskId: taskId.value, progress: 3 }).then((res) => {
message.success(res);
updateVisible(false);
emit("done");
});
}
});
};
const closeTask = () => {
Modal.confirm({
title: "提示",
content: "确定关闭工单吗?",
icon: createVNode(ExclamationCircleOutlined),
maskClosable: true,
onOk: () => {
updateTask({ taskId: taskId.value, status: 1, progress: 5 }).then((res) => {
message.success(res);
updateVisible(false);
emit("done");
});
}
});
}
const completedTask = () => {
Modal.confirm({
title: "提示",
content: "确定工单已完接了吗?",
icon: createVNode(ExclamationCircleOutlined),
maskClosable: true,
onOk: () => {
updateTask({ taskId: taskId.value, status: 1, progress: 4 }).then((res) => {
message.success(res);
updateVisible(false);
emit("done");
});
}
});
}
const openTask = () => {
Modal.confirm({
title: "提示",
content: "重新开启后可以继续追加内容,需要重新开启工单吗?",
icon: createVNode(ExclamationCircleOutlined),
maskClosable: true,
onOk: () => {
updateTask({ taskId: taskId.value, status: 0, progress: 2 }).then((res) => {
message.success(res);
updateVisible(false);
emit("done");
});
}
});
}
const showEdit = () => {
replyName.value = '';
parentId.value = 0;
openEdit.value = true;
openUsers.value = false;
};
const showUsers = () => {
openUsers.value = true;
openEdit.value = false;
}
const onReply = (row: TaskRecord) => {
parentId.value = row.taskRecordId;
replyName.value = row.nickname;
openEdit.value = true;
}
/* 删除单个 */
const remove = (row: TaskRecord) => {
removeTaskRecord(row.taskRecordId)
.then((msg) => {
message.success(msg);
// 加载工单明细
reload();
})
.catch((e) => {
message.error(e.message);
});
};
// 指派工单
const onUpdateTask = () => {
updateTask({
commander: commander.value,
taskId: taskId.value,
progress: 1
}).then(() => {
message.success("操作成功");
updateVisible(false);
emit('done');
}).catch(err => {
message.error(err.message)
})
};
// 选择用户
const onSelect = (userId) => {
commander.value = userId;
};
/* 保存编辑 */
const save = () => {
if(content.value == ''){
message.error('请填写回复内容!');
return false;
}
validate()
.then(() => {
loading.value = true;
const data = {
...form,
taskId: taskId.value,
parentId: parentId.value,
// 处理工单内容
content: content.value,
files: JSON.stringify(files.value)
};
// 转字符串
addTaskRecord(data)
.then((msg) => {
loading.value = false;
message.success(msg);
files.value = [];
content.value = '';
fileList.value = [];
// 加载工单明细
reload();
})
.catch((e) => {
loading.value = false;
message.error(e.message);
});
})
.catch(() => {
});
};
const onClose = () => {
emit("done");
}
/* 粘贴图片上传服务器并插入编辑器 */
const onPaste = (e) => {
const items = (e.clipboardData || e.originalEvent.clipboardData).items;
let hasFile = false;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
let file = items[i].getAsFile();
const item: ItemType = {
file,
uid: (file as any).lastModified,
name: file.name
};
uploadFile(<File>item.file)
.then((result) => {
const addPath = '!['+result.name+']('+FILE_SERVER + result.path+')\n\r';
content.value = content.value + addPath
})
.catch((e) => {
message.error(e.message);
});
hasFile = true;
}
}
if (hasFile) {
e.preventDefault();
}
}
const reload = () => {
// 加载工单明细
listTaskRecord({ taskId: taskId.value }).then((data) => {
record.value = toTreeData({
data: data.map((d) => {
return { ...d, key: d.taskRecordId, value: d.taskRecordId };
}),
idField: 'taskRecordId',
parentIdField: 'parentId'
});
loading.value = false;
});
// 加载应用信息
if (appId.value) {
getApp(appId.value).then(result => {
appInfo.value = result;
})
}
}
// 查看秘钥
const onAppSecret = (appId) => {
getAppSecret({ appId }).then((res) => {
showAppSecret.value = !showAppSecret.value;
});
};
const handleMax = () => {
if(width.value === '100%'){
width.value = '75%'
}else{
width.value = '100%'
}
}
watch(
() => props.visible,
(visible) => {
if (visible) {
if (props.data) {
detail.value = props.data;
appId.value = Number(props.data.appId);
active.value = Number(props.data.progress);
taskId.value = Number(props.data.taskId);
commander.value = props.data.commanderName;
reload();
}
} else {
resetFields();
}
}
);
</script>
<script lang="ts">
export default {
name: "TaskDetailContent"
};
</script>
<style lang="less" scoped>
.content {
background-color: #ffffff;
padding: 10px 0;
min-height: 40px;
max-width: 80%;
overflow: hidden;
.edit-md {
margin-top: 30px;
}
}
.screenshot {
margin: auto !important;
}
.task-card {
background-color: var(--body-background);
margin-bottom: 20px;
//margin: 20px auto;
.comment {
}
}
.task-btn {
display: flex;
justify-content: center;
margin: 20px;
}
.ant-page-header{
padding: 0 !important;
}
.replay-bg{
//box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.upload-list-inline :deep(.ant-upload-list-item) {
float: left;
width: 200px;
margin-right: 8px;
}
.upload-list-inline [class*='-upload-list-rtl'] :deep(.ant-upload-list-item) {
float: right;
}
/* 应用详情字段列表 */
.app-item-list {
& > .ele-cell {
padding: 16px 8px;
.ele-cell-content {
.ele-cell-desc {
margin-bottom: 15px;
}
.ele-cell-title {
max-width: 900px;
}
}
}
.app-item-icon {
color: #fff;
padding: 8px;
font-size: 26px;
border-radius: 50%;
&.anticon-qq {
background: #3492ed;
}
&.anticon-wechat {
background: #4daf29;
}
&.anticon-alipay {
background: #1476fe;
}
}
}
</style>