xiaoyi
3 天以前 b9128255d4438ae73db9e5ef01b5cb1f8e328aa2
feat: add process knowledge and after-sales workflows
已添加6个文件
已修改3个文件
572 ■■■■■ 文件已修改
src/api/aftersales/collaboration.ts 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/aftersales/ticket.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/erp/finance/after-sale-draft.ts 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/mes/pro/processdesign/knowledge/index.ts 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/aftersales/collaboration/index.vue 154 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/aftersales/ticket/index.vue 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/finance/after-sale-draft/index.vue 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/sale/order/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/mes/process-design/knowledge/index.vue 208 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/aftersales/collaboration.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,44 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export interface Collaboration {
  id: number;
  ticketId: number;
  category: string;
  title: string;
  content: string;
  assigneeUserId: number;
  requestedBy: number;
  status: number;
  result?: string;
  createTime?: string;
  completedTime?: string;
  amount?: string;
  direction?: string;
  partyName?: string;
  partyType?: string;
  partyId?: number;
  financeDraftId?: number;
  previousId?: number;
  businessApproverId?: number;
  businessRemark?: string;
  financeReview?: { status: number; remark?: string; reviewTime?: string };
}
export interface CreateCollaboration {
  ticketId?: number;
  category?: string;
  title: string;
  content: string;
  assigneeUserId?: number;
  amount?: string;
  direction?: string;
  partyName?: string;
  partyId?: number;
  previousId?: number;
}
export const getMyCollaborations = (params: PageParam) => requestClient.get<PageResult<Collaboration>>('/aftersales/collaboration/my-page', { params });
export const createCollaboration = (data: CreateCollaboration) => requestClient.post<number>('/aftersales/collaboration/create', data);
export const completeCollaboration = (id: number, result: string) => requestClient.put('/aftersales/collaboration/complete', { result }, { params: { id } });
export const cancelCollaboration = (id: number) => requestClient.put('/aftersales/collaboration/cancel', undefined, { params: { id } });
export const reviewCollaborationFinancial = (id: number, approved: boolean, remark?: string) =>
  requestClient.put('/aftersales/collaboration/review-financial', { approved, remark }, { params: { id } });
src/api/aftersales/ticket.ts
@@ -2,6 +2,11 @@
import { requestClient } from '#/api/request';
/** æŒ‰å½“前场景和用户权限统计各状态的售后工单数 */
export function getTicketOverview(sceneType: number) {
  return requestClient.get<Record<string, number>>('/aftersales/ticket/overview', { params: { sceneType } });
}
export namespace AftersalesTicketApi {
  /** å·¥å•明细行 */
  export interface TicketItem {
src/api/erp/finance/after-sale-draft.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,22 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export interface AfterSaleFinanceDraft {
  id: number;
  collaborationId: number;
  ticketId: number;
  category: string;
  direction: string;
  amount: string;
  partyName?: string;
  description: string;
  businessApproverId: number;
  status: number;
  reviewerId?: number;
  reviewRemark?: string;
  reviewTime?: string;
}
export const getAfterSaleFinanceDraftPage = (params: PageParam & { status?: number }) =>
  requestClient.get<PageResult<AfterSaleFinanceDraft>>('/erp/finance/after-sale-draft/page', { params });
export const reviewAfterSaleFinanceDraft = (id: number, approved: boolean, remark?: string) =>
  requestClient.put('/erp/finance/after-sale-draft/review', { approved, remark }, { params: { id } });
src/api/mes/pro/processdesign/knowledge/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,39 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
export interface KnowledgeResource {
  processId?: number;
  resourceType: 'MACHINE' | 'WORKSTATION' | 'OTHER';
  resourceId?: number;
  capabilityDescription: string;
}
export interface KnowledgeReference { id: number; targetType: string; targetId: number; knowledgeVersion: number; createTime?: string }
export interface Knowledge {
  id?: number;
  code: string;
  name: string;
  version?: number;
  productItemId?: number;
  bomId?: number;
  voltageMin?: number;
  voltageMax?: number;
  market: 'ALL' | 'DOMESTIC' | 'EXPORT';
  routeId?: number;
  templateId?: number;
  description?: string;
  status?: number;
  standards?: string[];
  resources?: KnowledgeResource[];
}
export const getKnowledgePage = (params: PageParam & { name?: string }) =>
  requestClient.get<PageResult<Knowledge>>('/mes/pd/knowledge/page', { params });
export const getKnowledge = (id: number) =>
  requestClient.get<{ knowledge: Knowledge; standards: string[]; resources: KnowledgeResource[]; references: KnowledgeReference[] }>('/mes/pd/knowledge/get', { params: { id } });
export const createKnowledge = (data: Knowledge) => requestClient.post<number>('/mes/pd/knowledge/create', data);
export const updateKnowledge = (data: Knowledge) => requestClient.put('/mes/pd/knowledge/update', data);
export const publishKnowledge = (id: number) => requestClient.put('/mes/pd/knowledge/publish', undefined, { params: { id } });
export const reviseKnowledge = (id: number) => requestClient.post<number>('/mes/pd/knowledge/revise', undefined, { params: { id } });
export const referenceKnowledgeRoute = (id: number, routeId: number) => requestClient.post('/mes/pd/knowledge/reference-route', undefined, { params: { id, routeId } });
export const matchKnowledge = (params: { productId?: number; bomId?: number; voltage?: number; market?: string; standards?: string[] }) =>
  requestClient.get<Knowledge[]>('/mes/pd/knowledge/match', { params: { ...params, standards: params.standards?.join(',') } });
src/views/aftersales/collaboration/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,154 @@
<script setup lang="ts">
import type { Collaboration, CreateCollaboration } from '#/api/aftersales/collaboration';
import { computed, onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { useUserStore } from '@vben/stores';
import { Button, Form, FormItem, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, message } from 'ant-design-vue';
import { cancelCollaboration, completeCollaboration, createCollaboration, getMyCollaborations, reviewCollaborationFinancial } from '#/api/aftersales/collaboration';
import { getTicketPage } from '#/api/aftersales/ticket';
import { UserSelect } from '#/views/system/user/components';
import SrmSupplierSelect from '#/views/srm/supplier/components/select.vue';
const userStore = useUserStore();
const items = ref<Collaboration[]>([]);
const page = ref(1);
const total = ref(0);
const loading = ref(false);
const saving = ref(false);
const createOpen = ref(false);
const finishOpen = ref(false);
const finishId = ref<number>();
const finishResult = ref('');
const reviewOpen = ref(false);
const reviewId = ref<number>();
const reviewApproved = ref(true);
const reviewRemark = ref('');
const tickets = ref<{ label: string; value: number }[]>([]);
let ticketRequest = 0;
const form = reactive<CreateCollaboration>({ title: '', content: '' });
const allowsAmount = computed(() => ['FINANCE', 'SUPPLIER', 'WARRANTY', 'QUALITY'].includes(form.category || ''));
const requiresAmount = computed(() => ['FINANCE', 'SUPPLIER', 'WARRANTY'].includes(form.category || ''));
const categories = [
  { value: 'QUALITY', label: '质量处理' }, { value: 'FINANCE', label: '财务协同' },
  { value: 'SUPPLIER', label: '供应商索赔' }, { value: 'WARRANTY', label: '三包处理' },
  { value: 'RESEARCH', label: '研发反馈' }, { value: 'PROCESS', label: '工艺反馈' },
];
const columns = [
  { title: '事项', dataIndex: 'title' }, { title: '类型', key: 'category' },
  { title: '工单编号', dataIndex: 'ticketId' }, { title: '发起人', dataIndex: 'requestedBy' },
  { title: '接收人', dataIndex: 'assigneeUserId' }, { title: '状态', key: 'status' },
  { title: '操作', key: 'actions', width: 160 },
];
async function load() {
  loading.value = true;
  try { const result = await getMyCollaborations({ pageNo: page.value, pageSize: 20 }); items.value = result.list; total.value = result.total; }
  catch { /* è¯·æ±‚层显示错误。 */ } finally { loading.value = false; }
}
async function searchTickets(keyword = '') {
  const request = ++ticketRequest;
  try {
    // å§‹ç»ˆæŒ‡å®šæ•°æ®æƒé™åœºæ™¯ï¼Œé¿å…æ— åœºæ™¯æ¡ä»¶æ—¶æ‰©å¤§å¯è§èŒƒå›´ã€‚
    const [owned, involved] = await Promise.all([
      getTicketPage({ pageNo: 1, pageSize: 100, sceneType: 1, no: keyword || undefined }),
      getTicketPage({ pageNo: 1, pageSize: 100, sceneType: 2, no: keyword || undefined }),
    ]);
    if (request !== ticketRequest) return;
    const unique = new Map<number, { label: string; value: number }>();
    for (const ticket of [...owned.list, ...involved.list]) if (ticket.id != null) unique.set(ticket.id, { label: `${ticket.no || ticket.id} ${ticket.name || ''}`, value: ticket.id });
    tickets.value = [...unique.values()];
  } catch { /* è¯·æ±‚层显示错误。 */ }
}
async function openCreate() {
  Object.assign(form, { ticketId: undefined, category: undefined, title: '', content: '', assigneeUserId: undefined,
    amount: undefined, direction: undefined, partyName: undefined, partyId: undefined, previousId: undefined });
  createOpen.value = true;
  await searchTickets();
}
async function retry(record: Collaboration) {
  Object.assign(form, { ticketId: record.ticketId, category: record.category, title: record.title, content: record.content,
    assigneeUserId: record.assigneeUserId, amount: record.amount, direction: record.direction, partyName: record.partyName, partyId: record.partyId,
    previousId: record.id });
  createOpen.value = true;
  await searchTickets();
  if (!tickets.value.some(ticket => ticket.value === record.ticketId))
    tickets.value.push({ value: record.ticketId, label: `工单 #${record.ticketId}` });
}
async function create() {
  if (!form.ticketId || !form.category || !form.title.trim() || !form.content.trim() || !form.assigneeUserId) { message.warning('请完整填写事项'); return; }
  if ((requiresAmount.value && form.amount == null) || (form.amount != null && (Number(form.amount) <= 0 || !form.direction
      || (form.category === 'SUPPLIER' && !form.partyId)
      || (!['SUPPLIER', 'WARRANTY'].includes(form.category) && form.direction !== 'INTERNAL' && !form.partyName?.trim())))) { message.warning('请填写有效金额、资金方向及往来方'); return; }
  saving.value = true;
  try { await createCollaboration(form); message.success('协同事项已发起'); createOpen.value = false; await load(); }
  catch { /* è¯·æ±‚层显示错误。 */ } finally { saving.value = false; }
}
function openFinish(id: number) { finishId.value = id; finishResult.value = ''; finishOpen.value = true; }
async function finish() {
  if (!finishId.value || !finishResult.value.trim()) { message.warning('请填写处理结果'); return; }
  saving.value = true;
  try { await completeCollaboration(finishId.value, finishResult.value); message.success('协同事项已完成'); finishOpen.value = false; await load(); }
  catch { /* è¯·æ±‚层显示错误。 */ } finally { saving.value = false; }
}
async function cancel(id: number) {
  try { await cancelCollaboration(id); message.success('协同事项已取消'); await load(); }
  catch { /* è¯·æ±‚层显示错误。 */ }
}
function changeCategory() { form.partyId = undefined; form.partyName = undefined; if (!allowsAmount.value) { form.amount = undefined; form.direction = undefined; } }
function openReview(id: number, approved: boolean) { reviewId.value = id; reviewApproved.value = approved; reviewRemark.value = ''; reviewOpen.value = true; }
async function review() {
  if (!reviewId.value || (!reviewApproved.value && !reviewRemark.value.trim())) { message.warning('退回时请填写审核意见'); return; }
  saving.value = true;
  try { await reviewCollaborationFinancial(reviewId.value, reviewApproved.value, reviewRemark.value || undefined);
    message.success(reviewApproved.value ? '业务审核通过,已生成财务草稿' : '已退回'); reviewOpen.value = false; await load(); }
  catch { /* è¯·æ±‚层显示错误。 */ } finally { saving.value = false; }
}
onMounted(load);
</script>
<template>
  <Page title="售后协同">
    <p class="mb-3 text-gray-500">涉及金额的事项在业务审核通过后生成财务草稿;草稿审核不会自动付款或记账。</p>
    <Button v-access:code="['aftersales:collaboration:create']" type="primary" class="mb-3" @click="openCreate">发起协同</Button>
    <Table :columns="columns" :data-source="items" :loading="loading" row-key="id"
      :pagination="{ current: page, pageSize: 20, total, showSizeChanger: false, onChange: (p: number) => { page = p; load(); } }">
      <template #expandedRowRender="{ record }"><p>事项内容:{{ record.content }}</p><p v-if="record.result">处理结果:{{ record.result }}</p>
        <p v-if="record.amount != null">金额:{{ record.amount }} å…ƒï¼›æ–¹å‘:{{ record.direction }};往来方:{{ record.partyName || '—' }}</p>
        <p v-if="record.financeDraftId">财务草稿编号:{{ record.financeDraftId }};财务审核:{{ record.financeReview?.status === 0 ? '待审核' : record.financeReview?.status === 1 ? '审核通过' : record.financeReview?.status === 2 ? '已退回' : '状态待确认' }}</p>
        <p v-if="record.previousId">上一协同事项编号:{{ record.previousId }}</p>
        <p v-if="record.financeReview?.remark">财务审核意见:{{ record.financeReview.remark }}</p><p v-if="record.businessRemark">业务审核意见:{{ record.businessRemark }}</p>
      </template>
      <template #bodyCell="{ column, record }">
        <template v-if="column.key === 'category'">{{ categories.find(item => item.value === record.category)?.label || record.category }}</template>
        <template v-else-if="column.key === 'status'"><Tag>{{ record.status === 1 ? (record.amount != null ? '待业务审核' : '已完成') : record.status === 2 ? '已取消' : record.status === 3 ? '已生成财务草稿' : record.status === 4 ? '业务已退回' : '待处理' }}</Tag></template>
        <template v-else-if="column.key === 'actions'"><Space>
          <Button v-if="record.status === 0 && record.assigneeUserId === Number(userStore.userInfo?.id)" v-access:code="['aftersales:collaboration:update']" type="link" @click="openFinish(record.id)">处理</Button>
          <Popconfirm v-if="record.status === 0 && record.requestedBy === Number(userStore.userInfo?.id)" title="确认取消该事项?" @confirm="cancel(record.id)"><Button v-access:code="['aftersales:collaboration:update']" type="link" danger>取消</Button></Popconfirm>
          <Button v-if="record.status === 1 && record.amount != null" v-access:code="['aftersales:collaboration:approve']" type="link" @click="openReview(record.id, true)">业务通过</Button>
          <Button v-if="record.status === 1 && record.amount != null" v-access:code="['aftersales:collaboration:approve']" type="link" danger @click="openReview(record.id, false)">业务退回</Button>
          <Button v-if="record.requestedBy === Number(userStore.userInfo?.id) && (record.status === 4 || (record.status === 2 && record.previousId != null) || (record.status === 3 && record.financeReview?.status === 2))" v-access:code="['aftersales:collaboration:create']" type="link" @click="retry(record as Collaboration)">重新发起</Button>
        </Space></template>
      </template>
    </Table>
    <Modal v-model:open="createOpen" title="发起售后协同" :confirm-loading="saving" @ok="create">
      <Form layout="vertical">
        <FormItem label="售后工单" required><Select v-model:value="form.ticketId" :options="tickets" :disabled="!!form.previousId" show-search :filter-option="false" placeholder="输入工单号搜索" @search="searchTickets" /></FormItem>
        <FormItem label="协同类型" required><Select v-model:value="form.category" :options="categories" :disabled="!!form.previousId" @change="changeCategory" /></FormItem>
        <FormItem label="事项名称" required><Input v-model:value="form.title" :maxlength="200" /></FormItem>
        <FormItem label="待处理内容" required><Input.TextArea v-model:value="form.content" :rows="3" :maxlength="2000" /></FormItem>
        <FormItem label="接收人" required><UserSelect v-model="form.assigneeUserId" /></FormItem>
        <FormItem v-if="allowsAmount" label="涉及金额(元)" :required="requiresAmount"><InputNumber v-model:value="form.amount" string-mode :min="0.01" :precision="2" class="w-full" /></FormItem>
        <FormItem v-if="form.amount != null" label="资金方向" required><Select v-model:value="form.direction" :options="[{label:'应收',value:'RECEIVABLE'},{label:'应付',value:'PAYABLE'},{label:'内部成本',value:'INTERNAL'}]" /></FormItem>
        <FormItem v-if="form.amount != null && form.category === 'SUPPLIER'" label="供应商" required><SrmSupplierSelect v-model="form.partyId" :approval-filter="false" :supplier-name="form.partyName" @change="item => form.partyName = item?.name" /></FormItem>
        <FormItem v-else-if="form.amount != null && form.category === 'WARRANTY'" label="客户">从售后工单关联的客户自动获取</FormItem>
        <FormItem v-else-if="form.amount != null && form.direction !== 'INTERNAL'" label="往来方名称" required><Input v-model:value="form.partyName" :maxlength="200" /></FormItem>
      </Form>
    </Modal>
    <Modal v-model:open="finishOpen" title="处理协同事项" :confirm-loading="saving" @ok="finish">
      <Input.TextArea v-model:value="finishResult" :rows="4" :maxlength="2000" placeholder="填写处理结果" />
    </Modal>
    <Modal v-model:open="reviewOpen" :title="reviewApproved ? '业务审核通过' : '业务审核退回'" :confirm-loading="saving" @ok="review">
      <p v-if="reviewApproved" class="mb-2">确认后将生成财务草稿,等待财务审核,不会自动付款或记账。</p>
      <Input.TextArea v-model:value="reviewRemark" :rows="3" :maxlength="1000" :placeholder="reviewApproved ? '审核意见(可选)' : '请填写退回原因'" />
    </Modal>
  </Page>
</template>
src/views/aftersales/ticket/index.vue
@@ -2,16 +2,16 @@
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { AftersalesTicketApi } from '#/api/aftersales/ticket';
import { ref } from 'vue';
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { Page, useVbenModal } from '@vben/common-ui';
import { AftersaleTicketStatusEnum } from '@vben/constants';
import { Button, message, Tabs, Tag } from 'ant-design-vue';
import { Button, Card, Col, message, Row, Statistic, Tabs, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteTicket, getTicketPage } from '#/api/aftersales/ticket';
import { deleteTicket, getTicketOverview, getTicketPage } from '#/api/aftersales/ticket';
import { cancelTicket } from '#/api/aftersales/ticket';
import { $t } from '#/locales';
@@ -29,11 +29,32 @@
}
const sceneType = ref('1');
const overview = ref<Record<string, number>>({});
const overviewLoading = ref(false);
let overviewRequest = 0;
const overviewItems = [
  { status: '0', label: '草稿' }, { status: '10', label: '处理中' },
  { status: '30', label: '待退货' }, { status: '40', label: '待退款/换货' },
  { status: '50', label: '已完结' }, { status: '60', label: '已关闭' },
  { status: '99', label: '已取消' },
];
async function loadOverview() {
  const request = ++overviewRequest;
  overview.value = {};
  overviewLoading.value = true;
  try {
    const result = await getTicketOverview(Number(sceneType.value));
    if (request === overviewRequest) overview.value = result;
  } catch { /* è¯·æ±‚层显示错误,避免显示其他场景的统计。 */ }
  finally { if (request === overviewRequest) overviewLoading.value = false; }
}
onMounted(loadOverview);
/** å¤„理场景类型的切换 */
function handleChangeSceneType(key: number | string) {
  sceneType.value = key.toString();
  gridApi.query();
  void loadOverview();
}
const [FormModal, formModalApi] = useVbenModal({
@@ -44,6 +65,7 @@
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
  void loadOverview();
}
/** åˆ›å»ºå·¥å• */
@@ -114,6 +136,12 @@
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <Row :gutter="12" class="mb-3">
      <Col v-for="item in overviewItems" :key="item.status" :xs="12" :xl="3">
        <Card size="small" :loading="overviewLoading"><Statistic :title="item.label" :value="overview[item.status] ?? '—'" /></Card>
      </Col>
    </Row>
    <Grid table-title="售后工单列表">
      <template #toolbar-actions>
        <Tabs class="w-full" @change="handleChangeSceneType">
src/views/erp/finance/after-sale-draft/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,64 @@
<script setup lang="ts">
import type { AfterSaleFinanceDraft } from '#/api/erp/finance/after-sale-draft';
import { onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Button, Input, Modal, Select, Space, Table, Tag, message } from 'ant-design-vue';
import { getAfterSaleFinanceDraftPage, reviewAfterSaleFinanceDraft } from '#/api/erp/finance/after-sale-draft';
const rows = ref<AfterSaleFinanceDraft[]>([]);
const page = ref(1);
const total = ref(0);
const status = ref<number>();
const loading = ref(false);
const saving = ref(false);
const dialogOpen = ref(false);
const reviewId = ref<number>();
const approved = ref(true);
const remark = ref('');
const categoryLabels: Record<string, string> = { QUALITY: '质量费用', FINANCE: '财务协同', SUPPLIER: '供应商索赔', WARRANTY: '三包赔付' };
const columns = [
  { title: '草稿编号', dataIndex: 'id' }, { title: '售后工单', dataIndex: 'ticketId' },
  { title: '协同事项', dataIndex: 'collaborationId' }, { title: '费用类型', key: 'category' }, { title: '资金方向', key: 'direction' },
  { title: '金额(元)', dataIndex: 'amount' }, { title: '往来方', dataIndex: 'partyName' },
  { title: '状态', key: 'status' }, { title: '操作', key: 'actions', width: 150 },
];
async function load() {
  loading.value = true;
  try { const result = await getAfterSaleFinanceDraftPage({ pageNo: page.value, pageSize: 20, status: status.value }); rows.value = result.list; total.value = result.total; }
  catch { /* è¯·æ±‚层显示错误。 */ } finally { loading.value = false; }
}
function openReview(id: number, passed: boolean) { reviewId.value = id; approved.value = passed; remark.value = ''; dialogOpen.value = true; }
async function review() {
  if (!reviewId.value || (!approved.value && !remark.value.trim())) { message.warning('退回时请填写审核意见'); return; }
  saving.value = true;
  try { await reviewAfterSaleFinanceDraft(reviewId.value, approved.value, remark.value || undefined); message.success('审核完成'); dialogOpen.value = false; await load(); }
  catch { /* è¯·æ±‚层显示错误。 */ } finally { saving.value = false; }
}
onMounted(load);
</script>
<template>
  <Page title="售后财务草稿">
    <p class="mb-3 text-gray-500">财务审核仅记录审核结论,不会自动生成付款、收款或会计凭证。</p>
    <Space class="mb-3"><Select v-model:value="status" allow-clear placeholder="审核状态" style="width: 160px"
      :options="[{label:'待审核',value:0},{label:'审核通过',value:1},{label:'已退回',value:2}]" @change="page = 1; load()" /></Space>
    <Table :columns="columns" :data-source="rows" :loading="loading" row-key="id"
      :pagination="{ current: page, pageSize: 20, total, showSizeChanger: false, onChange: (p: number) => { page = p; load(); } }">
      <template #expandedRowRender="{ record }"><p>业务说明:{{ record.description }}</p><p>业务审核人:{{ record.businessApproverId }}</p>
        <p v-if="record.reviewerId">财务审核人:{{ record.reviewerId }};意见:{{ record.reviewRemark || '—' }}</p></template>
      <template #bodyCell="{ column, record }">
        <template v-if="column.key === 'category'">{{ categoryLabels[record.category] || record.category }}</template>
        <template v-else-if="column.key === 'direction'">{{ record.direction === 'RECEIVABLE' ? '应收' : record.direction === 'PAYABLE' ? '应付' : '内部成本' }}</template>
        <template v-else-if="column.key === 'status'"><Tag>{{ record.status === 0 ? '待审核' : record.status === 1 ? '审核通过' : '已退回' }}</Tag></template>
        <template v-else-if="column.key === 'actions'"><Space v-if="record.status === 0">
          <Button v-access:code="['erp:after-sale-finance:review']" type="link" @click="openReview(record.id, true)">通过</Button>
          <Button v-access:code="['erp:after-sale-finance:review']" type="link" danger @click="openReview(record.id, false)">退回</Button>
        </Space></template>
      </template>
    </Table>
    <Modal v-model:open="dialogOpen" :title="approved ? '财务审核通过' : '财务审核退回'" :confirm-loading="saving" @ok="review">
      <p v-if="approved" class="mb-2">确认审核通过?此操作不会自动付款或记账。</p>
      <Input.TextArea v-model:value="remark" :rows="3" :maxlength="1000" :placeholder="approved ? '审核意见(可选)' : '请填写退回原因'" />
    </Modal>
  </Page>
</template>
src/views/erp/sale/order/index.vue
@@ -151,6 +151,8 @@
    await cancelSaleOrder(row.id!);
    message.success('作废成功');
    handleRefresh();
  } catch {
    // è¯·æ±‚层已提示失败原因,保留列表供用户处理后重试。
  } finally {
    hideLoading();
  }
src/views/mes/process-design/knowledge/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,208 @@
<script setup lang="ts">
import type { Knowledge, KnowledgeReference } from '#/api/mes/pro/processdesign/knowledge';
import { onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Button, Form, FormItem, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, message } from 'ant-design-vue';
import { createKnowledge, getKnowledge, getKnowledgePage, matchKnowledge, publishKnowledge, referenceKnowledgeRoute, reviseKnowledge, updateKnowledge } from '#/api/mes/pro/processdesign/knowledge';
import { getRouteSimpleList } from '#/api/mes/pro/route';
import { getTemplate, getTemplatePage } from '#/api/mes/pro/processdesign/template';
import { MdItemSelect, MdProductBomSelect } from '#/views/mes/md/item/components';
import DvMachinerySelect from '#/views/mes/dv/machinery/components/select.vue';
import MdWorkstationSelect from '#/views/mes/md/workstation/components/select.vue';
const rows = ref<Knowledge[]>([]);
const total = ref(0);
const loading = ref(false);
const saving = ref(false);
const dialogOpen = ref(false);
const readOnly = ref(false);
const referenceOpen = ref(false);
const referenceKnowledgeId = ref<number>();
const referenceRouteId = ref<number>();
const references = ref<KnowledgeReference[]>([]);
const name = ref('');
const page = ref(1);
const pageSize = 20;
const standardText = ref('');
const resources = ref<NonNullable<Knowledge['resources']>>([]);
const matchStandardText = ref('');
const matching = ref(false);
const matchResults = ref<Knowledge[] | null>(null);
const criteria = reactive<{ productId?: number; bomId?: number; voltage?: number; market?: string }>({});
const routes = ref<{ label: string; value: number }[]>([]);
const templates = ref<{ label: string; value: number }[]>([]);
let templateRequest = 0;
const form = reactive<Knowledge>({ code: '', name: '', market: 'ALL' });
const columns = [
  { title: '编码', dataIndex: 'code' }, { title: '名称', dataIndex: 'name' },
  { title: '版本', dataIndex: 'version' }, { title: '状态', key: 'status' },
  { title: '操作', key: 'actions', width: 160 },
];
async function load() {
  matchResults.value = null;
  loading.value = true;
  try {
    const result = await getKnowledgePage({ pageNo: page.value, pageSize, name: name.value || undefined });
    rows.value = result.list;
    total.value = result.total;
  } catch { /* è¯·æ±‚层显示错误。 */ } finally { loading.value = false; }
}
async function match() {
  matching.value = true;
  try {
    const standards = matchStandardText.value.split('\n').map(v => v.trim()).filter(Boolean);
    matchResults.value = await matchKnowledge({ ...criteria, standards: standards.length ? standards : undefined });
  } catch { /* è¯·æ±‚层显示错误。 */ } finally { matching.value = false; }
}
async function open(id?: number, viewOnly = false) {
  Object.assign(form, { id: undefined, code: '', name: '', market: 'ALL', productItemId: undefined,
    bomId: undefined, voltageMin: undefined, voltageMax: undefined, routeId: undefined,
    templateId: undefined, description: undefined, standards: [] });
  standardText.value = '';
  resources.value = [];
  references.value = [];
  readOnly.value = viewOnly;
  if (id) {
    try {
      const detail = await getKnowledge(id);
      if (!detail) { message.warning('知识不存在'); return; }
      Object.assign(form, detail.knowledge);
      standardText.value = detail.standards.join('\n');
      resources.value = detail.resources.map(item => ({ processId: item.processId, resourceType: item.resourceType,
        resourceId: item.resourceId, capabilityDescription: item.capabilityDescription }));
      references.value = detail.references;
      if (form.templateId != null && !templates.value.some(item => item.value === form.templateId)) {
        try {
          const template = await getTemplate(form.templateId);
          if (template) templates.value.push({ label: template.templateName || template.templateCode || String(form.templateId), value: form.templateId });
        } catch { /* ä¿ç•™çŸ¥è¯†è¯¦æƒ…;模板访问权限由各自的权限控制。 */ }
      }
    } catch { return; }
  }
  dialogOpen.value = true;
}
async function save() {
  if (!form.code.trim() || !form.name.trim()) { message.warning('请填写知识编码和名称'); return; }
  if (form.voltageMin != null && form.voltageMax != null && form.voltageMin > form.voltageMax) {
    message.warning('最低电压不能大于最高电压'); return;
  }
  saving.value = true;
  try {
    const data = { ...form, standards: standardText.value.split('\n').map(v => v.trim()).filter(Boolean), resources: resources.value };
    if (form.id) await updateKnowledge(data); else await createKnowledge(data);
    message.success('保存成功'); dialogOpen.value = false; await load();
  } catch { /* è¯·æ±‚层显示错误,保留表单以便重试。 */ } finally { saving.value = false; }
}
async function publish(id: number) {
  try { await publishKnowledge(id); message.success('发布成功'); await load(); }
  catch { /* è¯·æ±‚层显示错误。 */ }
}
async function revise(id: number) {
  try { const draftId = await reviseKnowledge(id); message.success('已创建新版本草稿'); await load(); await open(draftId); }
  catch { /* è¯·æ±‚层显示错误。 */ }
}
function openReference(id: number) {
  referenceKnowledgeId.value = id;
  referenceRouteId.value = undefined;
  referenceOpen.value = true;
}
async function saveReference() {
  if (referenceKnowledgeId.value == null || referenceRouteId.value == null) { message.warning('请选择目标工艺路线'); return; }
  saving.value = true;
  try {
    await referenceKnowledgeRoute(referenceKnowledgeId.value, referenceRouteId.value);
    message.success('已记录参考关系'); referenceOpen.value = false;
  } catch { /* è¯·æ±‚层显示错误。 */ } finally { saving.value = false; }
}
async function searchTemplates(keyword = '') {
  const request = ++templateRequest;
  try {
    const result = await getTemplatePage({ pageNo: 1, pageSize: 100, templateName: keyword || undefined });
    if (request === templateRequest) templates.value = result.list.filter(t => t.id != null)
      .map(t => ({ label: t.templateName || t.templateCode || String(t.id), value: t.id! }));
  } catch { /* è¯·æ±‚层显示错误。 */ }
}
onMounted(async () => {
  await load();
  const [routeResult] = await Promise.allSettled([getRouteSimpleList(), searchTemplates()]);
  if (routeResult.status === 'fulfilled')
    routes.value = routeResult.value.filter(r => r.id != null).map(r => ({ label: r.name || r.code || String(r.id), value: r.id! }));
});
</script>
<template>
  <Page title="工艺知识">
    <div class="mb-4 rounded bg-white p-4">
      <div class="mb-3 font-medium">匹配已发布的工艺知识</div>
      <div class="mb-3 text-gray-500">请填写已知条件;未填写标准时不校验标准,结果需由工艺人员确认。</div>
      <Space wrap>
        <MdItemSelect v-model="criteria.productId" placeholder="选择适用物料" style="width: 240px" @change="criteria.bomId = undefined" />
        <MdProductBomSelect v-model="criteria.bomId" :item-id="criteria.productId" :disabled="criteria.productId == null" placeholder="指定 BOM ç‰©æ–™ï¼ˆå¯é€‰ï¼‰" style="width: 240px" />
        <InputNumber v-model:value="criteria.voltage" :min="0" placeholder="电压(kV)" />
        <Select v-model:value="criteria.market" allow-clear placeholder="内外销" style="width: 120px" :options="[{label:'内销',value:'DOMESTIC'},{label:'外销',value:'EXPORT'}]" />
        <Input v-model:value="matchStandardText" placeholder="标准编码(单项)" style="width: 180px" />
        <Button :loading="matching" @click="match">匹配知识</Button>
        <Button v-if="matchResults !== null" @click="load">返回全部知识</Button>
      </Space>
    </div>
    <Space class="mb-4">
      <Input v-model:value="name" placeholder="知识名称" allow-clear @press-enter="page = 1; load()" />
      <Button @click="page = 1; load()">查询</Button>
      <Button v-access:code="['mes:pd-knowledge:create']" type="primary" @click="open()">新建知识</Button>
    </Space>
    <Table :columns="columns" :data-source="matchResults ?? rows" :loading="loading" row-key="id"
      :pagination="matchResults !== null ? false : { current: page, pageSize, total, showSizeChanger: false, onChange: (p: number) => { page = p; load(); } }">
      <template #bodyCell="{ column, record }">
        <template v-if="column.key === 'status'"><Tag>{{ record.status === 1 ? '已发布' : record.status === 2 ? '已停用' : '草稿' }}</Tag></template>
        <template v-else-if="column.key === 'actions'">
          <Space>
            <Button type="link" @click="open(record.id, true)">详情</Button>
            <Button v-if="record.status === 0" v-access:code="['mes:pd-knowledge:update']" type="link" @click="open(record.id)">编辑</Button>
            <template v-if="record.status === 0">
            <Popconfirm title="确认发布该版本?发布后不能直接修改。" @confirm="publish(record.id)">
              <Button v-access:code="['mes:pd-knowledge:publish']" type="link">发布</Button>
            </Popconfirm>
            </template>
            <Button v-if="record.status === 1" v-access:code="['mes:pd-knowledge:create']" type="link" @click="revise(record.id)">创建新版本</Button>
            <Button v-if="record.status === 1" v-access:code="['mes:pd-knowledge:reference']" type="link" @click="openReference(record.id)">记录参考</Button>
          </Space>
        </template>
      </template>
    </Table>
    <Modal v-model:open="dialogOpen" :title="readOnly ? '工艺知识详情' : form.id ? '编辑工艺知识草稿' : '新建工艺知识'" :footer="readOnly ? null : undefined" :confirm-loading="saving" @ok="save">
      <Form layout="vertical" :disabled="readOnly">
        <FormItem label="知识编码" required><Input v-model:value="form.code" :disabled="!!form.id" :maxlength="64" /></FormItem>
        <FormItem label="知识名称" required><Input v-model:value="form.name" :maxlength="200" /></FormItem>
        <FormItem label="适用市场"><Select v-model:value="form.market" :options="[{label:'全部',value:'ALL'},{label:'内销',value:'DOMESTIC'},{label:'外销',value:'EXPORT'}]" /></FormItem>
        <FormItem label="适用物料(留空表示不限)"><MdItemSelect v-model="form.productItemId" :disabled="readOnly" @change="form.bomId = undefined" /></FormItem>
        <FormItem label="适用 BOM ç‰©æ–™ï¼ˆå¯é€‰ï¼‰"><MdProductBomSelect v-model="form.bomId" :item-id="form.productItemId" :disabled="readOnly || form.productItemId == null" /></FormItem>
        <FormItem label="最低电压(kV)"><InputNumber v-model:value="form.voltageMin" :min="0" class="w-full" /></FormItem>
        <FormItem label="最高电压(kV)"><InputNumber v-model:value="form.voltageMax" :min="0" class="w-full" /></FormItem>
        <FormItem label="参考工艺路线"><Select v-model:value="form.routeId" :options="routes" allow-clear show-search option-filter-prop="label" /></FormItem>
        <FormItem label="参考参数模板"><Select v-model:value="form.templateId" :options="templates" allow-clear show-search :filter-option="false" @search="searchTemplates" /></FormItem>
        <FormItem label="适用标准编码(每行一项)"><Input.TextArea v-model:value="standardText" :rows="3" /></FormItem>
        <FormItem label="生产资源能力">
          <div v-for="(item, index) in resources" :key="index" class="mb-3 rounded border p-3">
            <Select v-model:value="item.resourceType" class="mb-2 w-full" :disabled="readOnly"
              :options="[{label:'设备',value:'MACHINE'},{label:'工作站',value:'WORKSTATION'},{label:'其他',value:'OTHER'}]"
              @change="item.resourceId = undefined" />
            <DvMachinerySelect v-if="item.resourceType === 'MACHINE'" v-model="item.resourceId" :disabled="readOnly" />
            <MdWorkstationSelect v-else-if="item.resourceType === 'WORKSTATION'" v-model="item.resourceId" :disabled="readOnly" />
            <Input.TextArea v-model:value="item.capabilityDescription" class="mt-2" :maxlength="500" :disabled="readOnly" placeholder="描述该资源的能力和约束" />
            <Button v-if="!readOnly" danger type="link" @click="resources.splice(index, 1)">移除资源</Button>
          </div>
          <Button v-if="!readOnly" type="dashed" @click="resources.push({ resourceType: 'OTHER', capabilityDescription: '' })">添加资源能力</Button>
        </FormItem>
        <FormItem v-if="readOnly && references.length" label="参考此版本的工艺路线">
          <div v-for="reference in references" :key="reference.id">{{ routes.find(r => r.value === reference.targetId)?.label || `路线 #${reference.targetId}` }}(版本 {{ reference.knowledgeVersion }})</div>
        </FormItem>
        <FormItem label="适用说明"><Input.TextArea v-model:value="form.description" :rows="3" /></FormItem>
      </Form>
    </Modal>
    <Modal v-model:open="referenceOpen" title="记录路线参考" :confirm-loading="saving" @ok="saveReference">
      <p class="mb-3">此操作只记录参考关系,不会复制或修改工艺路线。</p>
      <Select v-model:value="referenceRouteId" class="w-full" :options="routes" show-search option-filter-prop="label" placeholder="选择目标工艺路线" />
    </Modal>
  </Page>
</template>