feat: AI 回复 Markdown 渲染 + SSE 解析优化
- 引入 markdown-it + highlight.js 渲染 AI 回复内容 - SSE 解析改用双换行分割,解决跨 chunk 数据丢失 - 用户消息保持纯文本,AI 回复用 Markdown 渲染 - 添加代码高亮、列表、表格等样式
This commit is contained in:
parent
d513c76cd6
commit
9a50d06752
@ -18,6 +18,12 @@ VITE_INJECT_APP_LOADING=true
|
||||
# 打包后是否生成dist.zip
|
||||
VITE_ARCHIVER=true
|
||||
|
||||
# 文件系统服务
|
||||
VITE_FILE_API_URL=/file-api
|
||||
|
||||
# IM 即时通讯服务
|
||||
VITE_IM_API_URL=/im-api
|
||||
VITE_IM_SIGNALR_URL=/im-signalr/hubs/chat
|
||||
|
||||
# Workflow 工作流服务
|
||||
VITE_WORKFLOW_API_URL=/workflow-api
|
||||
|
||||
@ -1,8 +1,16 @@
|
||||
import { fileRequestClient } from '#/api/request';
|
||||
|
||||
// --- 枚举 ---
|
||||
|
||||
export enum ResourceType {
|
||||
File = 'file',
|
||||
Folder = 'folder',
|
||||
}
|
||||
|
||||
// --- 类型定义(file_system Go 响应为 PascalCase) ---
|
||||
|
||||
export namespace FileApi {
|
||||
// S3 原始文件信息
|
||||
export interface FileInfo {
|
||||
Key: string;
|
||||
Size: number;
|
||||
@ -21,9 +29,90 @@ export namespace FileApi {
|
||||
max_keys?: number;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
// 数据库文件元数据
|
||||
export interface FileMeta {
|
||||
ID: string;
|
||||
FolderID: string;
|
||||
Name: string;
|
||||
S3Key: string;
|
||||
S3Bucket: string;
|
||||
Size: number;
|
||||
ContentType: string;
|
||||
OwnerID: string;
|
||||
CreatedAt: string;
|
||||
UpdatedAt: string;
|
||||
}
|
||||
|
||||
// 分片上传
|
||||
export interface MultipartInitResult {
|
||||
upload_id: string;
|
||||
}
|
||||
|
||||
export interface UploadPartResult {
|
||||
etag: string;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 文件操作 ---
|
||||
export namespace FolderApi {
|
||||
export interface Folder {
|
||||
ID: string;
|
||||
ParentID: string | null;
|
||||
Name: string;
|
||||
OwnerID: string;
|
||||
CreatedAt: string;
|
||||
UpdatedAt: string;
|
||||
}
|
||||
|
||||
export interface FolderWithChildren {
|
||||
Folder: Folder;
|
||||
SubFolders: Folder[];
|
||||
Files: FileApi.FileMeta[];
|
||||
}
|
||||
|
||||
export interface CreateFolderParams {
|
||||
name: string;
|
||||
parent_id?: string | null;
|
||||
}
|
||||
|
||||
export interface RenameFolderParams {
|
||||
name: string;
|
||||
}
|
||||
}
|
||||
|
||||
export namespace ShareApi {
|
||||
export interface ShareLink {
|
||||
ID: string;
|
||||
ResourceType: string;
|
||||
ResourceID: string;
|
||||
Token: string;
|
||||
Password: string | null;
|
||||
ExpiresAt: string | null;
|
||||
DownloadCount: number;
|
||||
MaxDownloads: number | null;
|
||||
CreatedBy: string;
|
||||
CreatedAt: string;
|
||||
}
|
||||
|
||||
export interface ShareInfo {
|
||||
Token: string;
|
||||
ResourceType: string;
|
||||
FileName: string;
|
||||
FileSize: number;
|
||||
HasPassword: boolean;
|
||||
ExpiresAt: string | null;
|
||||
}
|
||||
|
||||
export interface CreateShareParams {
|
||||
resource_type: string;
|
||||
resource_id: string;
|
||||
password?: string;
|
||||
expires_at?: string;
|
||||
max_downloads?: number;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 文件操作(S3 原始) ---
|
||||
|
||||
export async function listFilesApi(params: FileApi.ListFilesParams) {
|
||||
return fileRequestClient.get<FileApi.ListFilesResult>('/files/list', {
|
||||
@ -65,6 +154,61 @@ export async function getFileContentApi(bucketName: string, objectKey: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// --- 分片上传 ---
|
||||
|
||||
export async function initMultipartUploadApi(
|
||||
bucketName: string,
|
||||
objectKey: string,
|
||||
) {
|
||||
return fileRequestClient.post<FileApi.MultipartInitResult>(
|
||||
'/files/multipart/init',
|
||||
{ bucket_name: bucketName, object_key: objectKey },
|
||||
);
|
||||
}
|
||||
|
||||
export async function uploadPartApi(
|
||||
bucketName: string,
|
||||
objectKey: string,
|
||||
uploadId: string,
|
||||
partNumber: number,
|
||||
data: Blob,
|
||||
) {
|
||||
const formData = new FormData();
|
||||
formData.append('bucket_name', bucketName);
|
||||
formData.append('object_key', objectKey);
|
||||
formData.append('upload_id', uploadId);
|
||||
formData.append('part_number', String(partNumber));
|
||||
formData.append('data', data);
|
||||
return fileRequestClient.put<FileApi.UploadPartResult>(
|
||||
'/files/multipart/part',
|
||||
formData,
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } },
|
||||
);
|
||||
}
|
||||
|
||||
export async function completeMultipartUploadApi(
|
||||
bucketName: string,
|
||||
objectKey: string,
|
||||
uploadId: string,
|
||||
parts: { part_number: number; etag: string }[],
|
||||
) {
|
||||
return fileRequestClient.post<{ message: string }>(
|
||||
'/files/multipart/complete',
|
||||
{ bucket_name: bucketName, object_key: objectKey, upload_id: uploadId, parts },
|
||||
);
|
||||
}
|
||||
|
||||
export async function abortMultipartUploadApi(
|
||||
bucketName: string,
|
||||
objectKey: string,
|
||||
uploadId: string,
|
||||
) {
|
||||
return fileRequestClient.post<{ message: string }>(
|
||||
'/files/multipart/abort',
|
||||
{ bucket_name: bucketName, object_key: objectKey, upload_id: uploadId },
|
||||
);
|
||||
}
|
||||
|
||||
// --- 存储桶操作 ---
|
||||
|
||||
export async function listBucketsApi() {
|
||||
@ -82,3 +226,68 @@ export async function deleteBucketApi(bucketName: string) {
|
||||
data: { bucket_name: bucketName },
|
||||
});
|
||||
}
|
||||
|
||||
// --- 文件夹操作 ---
|
||||
|
||||
export async function getFolderTreeApi() {
|
||||
return fileRequestClient.get<FolderApi.Folder[]>('/folders/tree');
|
||||
}
|
||||
|
||||
export async function getFolderApi(folderId: string) {
|
||||
return fileRequestClient.get<FolderApi.FolderWithChildren>(
|
||||
`/folders/${folderId}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createFolderApi(params: FolderApi.CreateFolderParams) {
|
||||
return fileRequestClient.post<FolderApi.Folder>('/folders', params);
|
||||
}
|
||||
|
||||
export async function renameFolderApi(
|
||||
folderId: string,
|
||||
params: FolderApi.RenameFolderParams,
|
||||
) {
|
||||
return fileRequestClient.put<FolderApi.Folder>(
|
||||
`/folders/${folderId}`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteFolderApi(folderId: string) {
|
||||
return fileRequestClient.delete<{ message: string }>(`/folders/${folderId}`);
|
||||
}
|
||||
|
||||
export async function uploadToFolderApi(
|
||||
folderId: string,
|
||||
bucket: string,
|
||||
file: File,
|
||||
) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('bucket', bucket);
|
||||
return fileRequestClient.post<FileApi.FileMeta>(
|
||||
`/folders/${folderId}/files`,
|
||||
formData,
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } },
|
||||
);
|
||||
}
|
||||
|
||||
export async function moveFileApi(fileId: string, targetFolderId: string) {
|
||||
return fileRequestClient.post<{ message: string }>(`/files/${fileId}/move`, {
|
||||
target_folder_id: targetFolderId,
|
||||
});
|
||||
}
|
||||
|
||||
// --- 分享操作 ---
|
||||
|
||||
export async function createShareApi(params: ShareApi.CreateShareParams) {
|
||||
return fileRequestClient.post<ShareApi.ShareLink>('/share', params);
|
||||
}
|
||||
|
||||
export async function deleteShareApi(shareId: string) {
|
||||
return fileRequestClient.delete<{ message: string }>(`/share/${shareId}`);
|
||||
}
|
||||
|
||||
export async function getShareInfoApi(token: string) {
|
||||
return fileRequestClient.get<ShareApi.ShareInfo>(`/share/${token}`);
|
||||
}
|
||||
|
||||
32
apps/web-antd/src/shims.d.ts
vendored
Normal file
32
apps/web-antd/src/shims.d.ts
vendored
Normal file
@ -0,0 +1,32 @@
|
||||
declare module 'markdown-it' {
|
||||
import type { PluginSimple, Renderer, ParserRules, PresetCores } from 'markdown-it';
|
||||
|
||||
interface Options {
|
||||
html?: boolean;
|
||||
xhtmlOut?: boolean;
|
||||
breaks?: boolean;
|
||||
langPrefix?: string;
|
||||
linkify?: boolean;
|
||||
typographer?: boolean;
|
||||
quotes?: string;
|
||||
highlight?: (str: string, lang: string) => string;
|
||||
}
|
||||
|
||||
type RenderRule = (tokens: any[], idx: number, options: Options, env: any, self: any) => string;
|
||||
|
||||
interface MarkdownIt {
|
||||
render(text: string, env?: any): string;
|
||||
utils: {
|
||||
escapeHtml(str: string): string;
|
||||
};
|
||||
set(options: Options): MarkdownIt;
|
||||
}
|
||||
|
||||
interface MarkdownItConstructor {
|
||||
new (preset?: string | PresetCores, options?: Options): MarkdownIt;
|
||||
(preset?: string | PresetCores, options?: Options): MarkdownIt;
|
||||
}
|
||||
|
||||
const MarkdownIt: MarkdownItConstructor;
|
||||
export default MarkdownIt;
|
||||
}
|
||||
@ -13,6 +13,9 @@ import {
|
||||
Spin,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import hljs from 'highlight.js';
|
||||
|
||||
import {
|
||||
createConversationApi,
|
||||
deleteConversationApi,
|
||||
@ -21,6 +24,22 @@ import {
|
||||
streamMessageApi,
|
||||
} from '#/api/core/ai-chat';
|
||||
|
||||
const md = new MarkdownIt({
|
||||
html: false,
|
||||
linkify: true,
|
||||
breaks: true,
|
||||
highlight(str: string, lang: string) {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
try {
|
||||
return `<pre class="hljs"><code>${hljs.highlight(str, { language: lang }).value}</code></pre>`;
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
}
|
||||
return `<pre class="hljs"><code>${md.utils.escapeHtml(str)}</code></pre>`;
|
||||
},
|
||||
});
|
||||
|
||||
const inputMessage = ref('');
|
||||
const sending = ref(false);
|
||||
const loading = ref(false);
|
||||
@ -46,6 +65,10 @@ function formatTime(dateStr: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function renderMarkdown(text: string) {
|
||||
return md.render(text);
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
nextTick(() => {
|
||||
if (messageListRef.value) {
|
||||
@ -140,23 +163,35 @@ async function handleSend(e?: any) {
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let aiContent = '';
|
||||
let buffer = '';
|
||||
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const text = decoder.decode(value, { stream: true });
|
||||
const lines = text.split('\n');
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: done')) continue;
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const data = line.slice(6).trim();
|
||||
if (data === '[DONE]') continue;
|
||||
// 按双换行分割 SSE 事件
|
||||
const parts = buffer.split('\n\n');
|
||||
// 最后一段可能不完整,保留到下次处理
|
||||
buffer = parts.pop() ?? '';
|
||||
|
||||
for (const part of parts) {
|
||||
const lines = part.split('\n');
|
||||
let dataLine = '';
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: done')) {
|
||||
break;
|
||||
}
|
||||
if (line.startsWith('data: ')) {
|
||||
dataLine = line.slice(6).trim();
|
||||
}
|
||||
}
|
||||
if (!dataLine || dataLine === '[DONE]') continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const parsed = JSON.parse(dataLine);
|
||||
if (parsed.content) {
|
||||
aiContent += parsed.content;
|
||||
const msg = messages.value.find((m) => m.id === aiMsgId);
|
||||
@ -164,7 +199,7 @@ async function handleSend(e?: any) {
|
||||
scrollToBottom();
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
// skip malformed JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -270,15 +305,17 @@ onMounted(loadConversations);
|
||||
{{ msg.role === 'User' ? '你' : 'AI 助手' }}
|
||||
</div>
|
||||
<div
|
||||
class="inline-block max-w-[70%] whitespace-pre-wrap break-words rounded-lg px-3 py-2 text-left"
|
||||
class="inline-block max-w-[70%] break-words rounded-lg px-3 py-2 text-left"
|
||||
:class="
|
||||
msg.role === 'User'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
? 'bg-primary text-primary-foreground whitespace-pre-wrap'
|
||||
: 'bg-muted text-foreground'
|
||||
"
|
||||
>
|
||||
<Spin v-if="!msg.content" size="small" />
|
||||
<template v-else>{{ msg.content }}</template>
|
||||
<template v-else-if="msg.role === 'User'">{{ msg.content }}</template>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-else class="markdown-body" v-html="renderMarkdown(msg.content)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -324,3 +361,69 @@ onMounted(loadConversations);
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.markdown-body {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.markdown-body :deep(p) {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.markdown-body :deep(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.markdown-body :deep(pre) {
|
||||
margin: 8px 0;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
background: #1e1e2e;
|
||||
}
|
||||
|
||||
.markdown-body :deep(code) {
|
||||
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.markdown-body :deep(:not(pre) > code) {
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.markdown-body :deep(ul),
|
||||
.markdown-body :deep(ol) {
|
||||
margin: 8px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.markdown-body :deep(blockquote) {
|
||||
margin: 8px 0;
|
||||
padding-left: 12px;
|
||||
border-left: 3px solid rgba(0, 0, 0, 0.15);
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.markdown-body :deep(table) {
|
||||
border-collapse: collapse;
|
||||
margin: 8px 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.markdown-body :deep(th),
|
||||
.markdown-body :deep(td) {
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
padding: 6px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown-body :deep(th) {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -35,8 +35,11 @@ const pageSize = ref(20);
|
||||
|
||||
const createVisible = ref(false);
|
||||
const editVisible = ref(false);
|
||||
const startVisible = ref(false);
|
||||
const formState = ref({ name: '', code: '', description: '' });
|
||||
const editingId = ref('');
|
||||
const startFormState = ref({ title: '', variables: '' });
|
||||
const startDef = ref<WorkflowDefinitionDto | null>(null);
|
||||
|
||||
const statusMap: Record<number, { color: string; text: string }> = {
|
||||
[DefinitionStatus.Draft]: { color: 'blue', text: '草稿' },
|
||||
@ -147,13 +150,26 @@ async function handleDisable(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStart(def: WorkflowDefinitionDto) {
|
||||
function openStartForm(def: WorkflowDefinitionDto) {
|
||||
startDef.value = def;
|
||||
startFormState.value = { title: def.name, variables: '' };
|
||||
startVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleStart() {
|
||||
if (!startDef.value) return;
|
||||
if (!startFormState.value.title.trim()) {
|
||||
message.warning('请填写流程标题');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await startWorkflowInstanceApi({
|
||||
definitionCode: def.code,
|
||||
title: def.name,
|
||||
definitionCode: startDef.value.code,
|
||||
title: startFormState.value.title,
|
||||
variables: startFormState.value.variables.trim() || undefined,
|
||||
});
|
||||
message.success(`「${def.name}」流程已发起`);
|
||||
message.success(`「${startFormState.value.title}」流程已发起`);
|
||||
startVisible.value = false;
|
||||
} catch {
|
||||
message.error('发起失败');
|
||||
}
|
||||
@ -194,7 +210,7 @@ onMounted(() => loadData());
|
||||
v-if="(record as WorkflowDefinitionDto).status === DefinitionStatus.Published && (record as WorkflowDefinitionDto).isEnabled"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handleStart(record as WorkflowDefinitionDto)"
|
||||
@click="openStartForm(record as WorkflowDefinitionDto)"
|
||||
>
|
||||
发起
|
||||
</Button>
|
||||
@ -233,5 +249,24 @@ onMounted(() => loadData());
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
v-model:open="startVisible"
|
||||
:title="`发起流程 - ${startDef?.name ?? ''}`"
|
||||
@ok="handleStart"
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="流程标题" required>
|
||||
<Input v-model:value="startFormState.title" placeholder="请输入流程标题" />
|
||||
</Form.Item>
|
||||
<Form.Item label="流程变量(JSON)">
|
||||
<Input.TextArea
|
||||
v-model:value="startFormState.variables"
|
||||
placeholder='可选,例如:{"reason": "出差申请", "amount": 5000}'
|
||||
:rows="4"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
import { h, onMounted, ref } from 'vue';
|
||||
import { computed, h, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import {
|
||||
Button,
|
||||
message,
|
||||
@ -22,14 +24,18 @@ import {
|
||||
withdrawWorkflowInstanceApi,
|
||||
type WorkflowInstanceDto,
|
||||
} from '#/api/core';
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const userId = computed(() => userStore.userInfo?.userId ?? '');
|
||||
const loading = ref(false);
|
||||
const data = ref<WorkflowInstanceDto[]>([]);
|
||||
const total = ref(0);
|
||||
const pageIndex = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const statusFilter = ref<InstanceStatus | undefined>(undefined);
|
||||
const statusFilter = ref<number | null>(null);
|
||||
const userMap = ref<Record<string, string>>({});
|
||||
|
||||
const instanceStatusMap: Record<number, { color: string; text: string }> = {
|
||||
[InstanceStatus.Pending]: { color: 'blue', text: '待启动' },
|
||||
@ -40,7 +46,7 @@ const instanceStatusMap: Record<number, { color: string; text: string }> = {
|
||||
};
|
||||
|
||||
const statusOptions = [
|
||||
{ value: undefined, label: '全部状态' },
|
||||
{ value: null, label: '全部状态' },
|
||||
{ value: InstanceStatus.Pending, label: '待启动' },
|
||||
{ value: InstanceStatus.Running, label: '运行中' },
|
||||
{ value: InstanceStatus.Suspended, label: '已挂起' },
|
||||
@ -66,6 +72,7 @@ const columns = [
|
||||
key: 'initiatorId',
|
||||
ellipsis: true,
|
||||
width: 200,
|
||||
customRender: ({ text }: { text: string }) => userMap.value[text] || text,
|
||||
},
|
||||
{
|
||||
title: '启动时间',
|
||||
@ -84,15 +91,32 @@ const columns = [
|
||||
{ title: '操作', key: 'actions', width: 240 },
|
||||
];
|
||||
|
||||
async function loadUserNames(ids: string[]) {
|
||||
if (ids.length === 0) return;
|
||||
const missing = ids.filter((id) => !userMap.value[id]);
|
||||
if (missing.length === 0) return;
|
||||
try {
|
||||
const users = await requestClient.get<{ id: string; username: string }[]>('/users');
|
||||
for (const u of users) {
|
||||
userMap.value[u.id] = u.username;
|
||||
}
|
||||
} catch {
|
||||
// ignore — will show raw ID
|
||||
}
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getWorkflowInstancesApi({
|
||||
pageIndex: pageIndex.value,
|
||||
pageSize: pageSize.value,
|
||||
status: statusFilter.value,
|
||||
status: statusFilter.value ?? undefined,
|
||||
});
|
||||
data.value = res.items ?? [];
|
||||
const items = res.items ?? [];
|
||||
const ids = [...new Set(items.map((d) => d.initiatorId))];
|
||||
await loadUserNames(ids);
|
||||
data.value = items;
|
||||
total.value = res.total ?? 0;
|
||||
} catch {
|
||||
message.error('加载流程实例失败');
|
||||
@ -134,7 +158,7 @@ async function handleResume(id: string) {
|
||||
|
||||
async function handleWithdraw(id: string) {
|
||||
try {
|
||||
await withdrawWorkflowInstanceApi(id, '');
|
||||
await withdrawWorkflowInstanceApi(id, userId.value);
|
||||
message.success('已撤回');
|
||||
loadData();
|
||||
} catch {
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
import { h, onMounted, ref } from 'vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
message,
|
||||
Modal,
|
||||
Spin,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
@ -21,6 +24,10 @@ import {
|
||||
const loading = ref(false);
|
||||
const definitions = ref<WorkflowDefinitionDto[]>([]);
|
||||
|
||||
const startVisible = ref(false);
|
||||
const startFormState = ref({ title: '', variables: '' });
|
||||
const currentDef = ref<WorkflowDefinitionDto | null>(null);
|
||||
|
||||
const statusMap: Record<number, { color: string; text: string }> = {
|
||||
[DefinitionStatus.Draft]: { color: 'blue', text: '草稿' },
|
||||
[DefinitionStatus.Published]: { color: 'green', text: '已发布' },
|
||||
@ -41,13 +48,26 @@ async function loadDefinitions() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStart(def: WorkflowDefinitionDto) {
|
||||
function openStartForm(def: WorkflowDefinitionDto) {
|
||||
currentDef.value = def;
|
||||
startFormState.value = { title: def.name, variables: '' };
|
||||
startVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleStart() {
|
||||
if (!currentDef.value) return;
|
||||
if (!startFormState.value.title.trim()) {
|
||||
message.warning('请填写流程标题');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await startWorkflowInstanceApi({
|
||||
definitionCode: def.code,
|
||||
title: def.name,
|
||||
definitionCode: currentDef.value.code,
|
||||
title: startFormState.value.title,
|
||||
variables: startFormState.value.variables.trim() || undefined,
|
||||
});
|
||||
message.success(`「${def.name}」流程已发起`);
|
||||
message.success(`「${startFormState.value.title}」流程已发起`);
|
||||
startVisible.value = false;
|
||||
} catch {
|
||||
message.error('发起失败');
|
||||
}
|
||||
@ -60,7 +80,7 @@ onMounted(() => loadDefinitions());
|
||||
<Page auto-content-height>
|
||||
<div class="mb-4">
|
||||
<h3 class="text-lg font-semibold">发起流程</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">点击已发布的流程即可发起</p>
|
||||
<p class="mt-1 text-sm text-gray-500">选择需要发起的流程,填写信息后提交</p>
|
||||
</div>
|
||||
|
||||
<Spin :spinning="loading">
|
||||
@ -68,9 +88,7 @@ onMounted(() => loadDefinitions());
|
||||
<Card
|
||||
v-for="def in definitions"
|
||||
:key="def.id"
|
||||
hoverable
|
||||
class="cursor-pointer transition-shadow hover:shadow-md"
|
||||
@click="handleStart(def)"
|
||||
class="transition-shadow hover:shadow-md"
|
||||
>
|
||||
<template #title>
|
||||
<span class="text-base">{{ def.name }}</span>
|
||||
@ -82,7 +100,7 @@ onMounted(() => loadDefinitions());
|
||||
{{ def.description || '暂无描述' }}
|
||||
</p>
|
||||
<div class="mt-3 text-right">
|
||||
<Button type="primary" size="small">发起</Button>
|
||||
<Button type="primary" size="small" @click="openStartForm(def)">发起</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
@ -90,5 +108,24 @@ onMounted(() => loadDefinitions());
|
||||
暂无可发起的流程
|
||||
</div>
|
||||
</Spin>
|
||||
|
||||
<Modal
|
||||
v-model:open="startVisible"
|
||||
:title="`发起流程 - ${currentDef?.name ?? ''}`"
|
||||
@ok="handleStart"
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="流程标题" required>
|
||||
<Input v-model:value="startFormState.title" placeholder="请输入流程标题" />
|
||||
</Form.Item>
|
||||
<Form.Item label="流程变量(JSON)">
|
||||
<Input.TextArea
|
||||
v-model:value="startFormState.variables"
|
||||
placeholder='可选,例如:{"reason": "出差申请", "amount": 5000}'
|
||||
:rows="4"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
48
pnpm-lock.yaml
generated
48
pnpm-lock.yaml
generated
@ -645,6 +645,12 @@ importers:
|
||||
dayjs:
|
||||
specifier: 'catalog:'
|
||||
version: 1.11.20
|
||||
highlight.js:
|
||||
specifier: ^11.11.1
|
||||
version: 11.11.1
|
||||
markdown-it:
|
||||
specifier: ^14.1.1
|
||||
version: 14.1.1
|
||||
pinia:
|
||||
specifier: ^3.0.4
|
||||
version: 3.0.4(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3))
|
||||
@ -6628,6 +6634,10 @@ packages:
|
||||
hey-listen@1.0.8:
|
||||
resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==}
|
||||
|
||||
highlight.js@11.11.1:
|
||||
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
homedir-polyfill@1.0.3:
|
||||
resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@ -7272,6 +7282,9 @@ packages:
|
||||
lines-and-columns@1.2.4:
|
||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||
|
||||
linkify-it@5.0.0:
|
||||
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
|
||||
|
||||
linkifyjs@4.3.2:
|
||||
resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==}
|
||||
|
||||
@ -7398,6 +7411,10 @@ packages:
|
||||
resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
markdown-it@14.1.1:
|
||||
resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==}
|
||||
hasBin: true
|
||||
|
||||
math-intrinsics@1.1.0:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@ -7414,6 +7431,9 @@ packages:
|
||||
mdn-data@2.28.0:
|
||||
resolution: {integrity: sha512-uy9AS1yt+wW5eUEefgE3lOpqPghanUttycV0GXKbiXyBjwvbeE8XPj4u1C+voRfz7dEjwU4NDHTMfZ/s/JtZrQ==}
|
||||
|
||||
mdurl@2.0.0:
|
||||
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
|
||||
|
||||
meow@13.2.0:
|
||||
resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==}
|
||||
engines: {node: '>=18'}
|
||||
@ -8081,6 +8101,10 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
punycode.js@2.3.1:
|
||||
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
punycode@2.3.1:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
@ -9114,6 +9138,9 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
uc.micro@2.1.0:
|
||||
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
|
||||
|
||||
ufo@1.6.3:
|
||||
resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==}
|
||||
|
||||
@ -15117,6 +15144,8 @@ snapshots:
|
||||
|
||||
hey-listen@1.0.8: {}
|
||||
|
||||
highlight.js@11.11.1: {}
|
||||
|
||||
homedir-polyfill@1.0.3:
|
||||
dependencies:
|
||||
parse-passwd: 1.0.0
|
||||
@ -15673,6 +15702,10 @@ snapshots:
|
||||
|
||||
lines-and-columns@1.2.4: {}
|
||||
|
||||
linkify-it@5.0.0:
|
||||
dependencies:
|
||||
uc.micro: 2.1.0
|
||||
|
||||
linkifyjs@4.3.2: {}
|
||||
|
||||
listhen@1.9.1:
|
||||
@ -15814,6 +15847,15 @@ snapshots:
|
||||
semver: 5.7.2
|
||||
optional: true
|
||||
|
||||
markdown-it@14.1.1:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
entities: 4.5.0
|
||||
linkify-it: 5.0.0
|
||||
mdurl: 2.0.0
|
||||
punycode.js: 2.3.1
|
||||
uc.micro: 2.1.0
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
mathml-tag-names@4.0.0: {}
|
||||
@ -15824,6 +15866,8 @@ snapshots:
|
||||
|
||||
mdn-data@2.28.0: {}
|
||||
|
||||
mdurl@2.0.0: {}
|
||||
|
||||
meow@13.2.0: {}
|
||||
|
||||
meow@14.1.0: {}
|
||||
@ -16587,6 +16631,8 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
sade: 1.8.1
|
||||
|
||||
punycode.js@2.3.1: {}
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
pupa@3.3.0:
|
||||
@ -17726,6 +17772,8 @@ snapshots:
|
||||
|
||||
typescript@6.0.3: {}
|
||||
|
||||
uc.micro@2.1.0: {}
|
||||
|
||||
ufo@1.6.3: {}
|
||||
|
||||
ultrahtml@1.6.0: {}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user