gaoluyang
5 天以前 af85dd610f4a363abfb3e7ae26f1a3fcf06ee590
feat(app): 移除 App 端全部附件上传、下载、预览功能

按要求 App 不再提供任何附件能力:
- 删除 4 处跟进图片上传(客户/合同/线索/商机)与素材 SIP/SOP 图片上传
- 删除售后工单/问题单附件展示、退货质检报告预览
- 删除采购/销售订单附件列表、供应商证书附件、发票与报价附件状态
- 删除设计资料预览、归档资料预览下载、BPM 业务附件配置页及其路由
- 清理各 api 文件中已无调用方的 upload*/choose*File 上传封装

Co-Authored-By: Claude Code <noreply@anthropic.com>
已删除4个文件
已修改36个文件
870 ■■■■■ 文件已修改
src/api/aftersales.ts 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/bpm.ts 132 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/crm.ts 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm.ts 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/process-design.ts 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/product-design.ts 78 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/srm.ts 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/supplier.ts 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/config/app-routes.ts 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages.json 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/aftersales/AftersalesAttachmentField.vue 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/aftersales/problem/form.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/aftersales/return/detail.vue 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/aftersales/ticket/detail.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/aftersales/ticket/form.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/bpm/business-attach/form.vue 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/bpm/business-attach/index.vue 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/bpm/components/BpmApprovalFlow.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/bpm/components/BpmFormRenderer.vue 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/bpm/form-parser.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/crm/business/detail.vue 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/crm/clue/detail.vue 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/crm/contract/detail.vue 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/crm/customer/detail.vue 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/crm/invoice/detail.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/crm/invoice/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/crm/sale-quotation/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/hrm/HrmAttachmentField.vue 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/hrm/attendance-exception/form.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/hrm/employee-contract/form.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/hrm/leave/form.vue 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/hrm/resignation/form.vue 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/hrm/transfer/form.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/mes/md/item/form.vue 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/mes/pd/archive/detail.vue 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/mes/pd/project/detail.vue 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/purchase/components/PurchaseDetail.vue 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/purchase/invoice/index.vue 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/sale/order/detail.vue 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/supplier/certificate/index.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/aftersales.ts
@@ -122,27 +122,3 @@
export function getVisit(token: string, id: number) { return request<AftersalesVisit>(`/aftersales/visit/get${query({ id })}`, { header: header(token) }); }
export function getVisitListByTicketId(token: string, ticketId: number) { return request<AftersalesVisit[]>(`/aftersales/visit/list${query({ ticketId })}`, { header: header(token) }); }
export function getVisitPage(token: string, params: { pageNo: number; pageSize: number; ticketId?: number }) { return request<PageResult<AftersalesVisit>>(`/aftersales/visit/page${query(params)}`, { header: header(token) }); }
export function uploadAftersalesFile(token: string, filePath: string) {
  return new Promise<AftersalesAttachment>((resolve, reject) => {
    uni.uploadFile({ url: `${API_BASE_URL}/system/storage-blob/upload`, filePath, name: "files", header: header(token),
      formData: { directory: "aftersales" }, success: response => {
        try {
          const result = JSON.parse(response.data) as ApiResponse<AftersalesAttachment[]>;
          if (result.code === 0 && result.data?.[0]) resolve(result.data[0]);
          else reject(new Error(result.msg || "文件上传失败"));
        } catch { reject(new Error("文件上传响应解析失败")); }
      }, fail: reject });
  });
}
export function chooseAftersalesFiles(count = 10): Promise<Array<{ path: string; name?: string }>> {
  return new Promise((resolve, reject) => {
    const done = (files: Array<{ path: string; name?: string }>) => resolve(files.filter(item => Boolean(item.path)));
    // #ifdef MP-WEIXIN
    uni.chooseMessageFile({ count, type: "file", success: result => done((result.tempFiles || []).map(item => ({ path: item.path, name: item.name }))), fail: reject });
    // #endif
    // #ifndef MP-WEIXIN
    uni.chooseFile({ count, success: result => done((result.tempFiles || []).map(item => ({ path: item.path, name: item.name }))), fail: reject });
    // #endif
  });
}
src/api/bpm.ts
@@ -413,40 +413,6 @@
  return request<boolean>(`/bpm/process-expression/delete${query({ id })}`, { method: "DELETE", header: header(accessToken) });
}
export interface BpmBusinessAttach {
  id?: number;
  name: string;
  code?: string;
  status?: number;
  processDefinitionKey?: string;
  taskDefinitionKey?: string;
  fileTypes?: string;
  maxSize?: number;
  required?: boolean;
  remark?: string;
  createTime?: number | string;
}
export function getBusinessAttachPage(accessToken: string, params: { pageNo: number; pageSize: number; name?: string }) {
  return request<PageResult<BpmBusinessAttach>>(`/bpm/business-attach/page${query(params)}`, { header: header(accessToken) });
}
export function getBusinessAttach(accessToken: string, id: number) {
  return request<BpmBusinessAttach>(`/bpm/business-attach/get${query({ id })}`, { header: header(accessToken) });
}
export function createBusinessAttach(accessToken: string, data: BpmBusinessAttach) {
  return request<number>("/bpm/business-attach/create", { method: "POST", data, header: header(accessToken) });
}
export function updateBusinessAttach(accessToken: string, data: BpmBusinessAttach) {
  return request<boolean>("/bpm/business-attach/update", { method: "PUT", data, header: header(accessToken) });
}
export function deleteBusinessAttach(accessToken: string, id: number) {
  return request<boolean>(`/bpm/business-attach/delete${query({ id })}`, { method: "DELETE", header: header(accessToken) });
}
/* ---------- æµç¨‹å®šä¹‰ ---------- */
/** å¯ç”¨ä¸­çš„æµç¨‹å®šä¹‰åˆ—表(发起流程页面用,suspensionState ä¸ºå¿…填) */
@@ -749,102 +715,4 @@
    `/bpm/task/list-by-process-instance-id?processInstanceId=${encodeURIComponent(processInstanceId)}`,
    { header: header(accessToken) },
  );
}
/* ---------- æ–‡ä»¶ä¸Šä¼  ---------- */
export interface BpmUploadResult {
  id?: number;
  name?: string;
  url?: string;
  path?: string;
  size?: number;
  type?: string;
}
/** ä¸Šä¼  BPM é™„ä»¶ / ç­¾åå›¾ç‰‡ï¼ˆå­—段名 file,与 web ç«¯ requestClient.upload ä¸€è‡´ï¼‰ */
export function uploadBpmFile(accessToken: string, filePath: string, directory = "bpm/task-attachment") {
  return new Promise<BpmUploadResult>((resolve, reject) => {
    uni.uploadFile({
      url: `${API_BASE_URL}/infra/file/upload`,
      filePath,
      name: "file",
      header: { Authorization: `Bearer ${accessToken}` },
      formData: { directory },
      success: response => {
        try {
          const data = JSON.parse(response.data) as {
            code?: number;
            msg?: string;
            data?: BpmUploadResult;
          };
          if (data.code === 0 && data.data) resolve(data.data);
          else reject(new Error(data.msg || "文件上传失败"));
        } catch {
          reject(new Error("文件上传响应解析失败"));
        }
      },
      fail: () => reject(new Error("文件上传失败,请检查网络后重试")),
    });
  });
}
/** æœ¬åœ°é€‰ä¸­çš„待上传文件 */
export interface BpmPickedFile {
  path: string;
  name?: string;
}
/**
 * è·¨ç«¯æ–‡ä»¶é€‰æ‹©ï¼ˆH5 / å¾®ä¿¡å°ç¨‹åº / App ä¸‰ç«¯å…¼å®¹ï¼‰
 * - H5 / App:uni.chooseFile
 * - å¾®ä¿¡å°ç¨‹åºï¼šuni.chooseMessageFile(uni.chooseFile åœ¨å°ç¨‹åºä¸å¯ç”¨ï¼‰
 */
export function chooseBpmFile(count = 9): Promise<BpmPickedFile[]> {
  return new Promise((resolve, reject) => {
    const onSuccess = (files: BpmPickedFile[]) => resolve(files);
    const onFail = (error: unknown) => reject(error);
    // #ifdef MP-WEIXIN
    uni.chooseMessageFile({
      count,
      type: "file",
      success: result => {
        onSuccess(
          (result.tempFiles || [])
            .map(item => ({ path: item.path, name: item.name }))
            .filter(item => Boolean(item.path)),
        );
      },
      fail: onFail,
    });
    // #endif
    // #ifndef MP-WEIXIN
    uni.chooseFile({
      count,
      success: result => {
        onSuccess(
          (result.tempFiles || [])
            .map(item => ({ path: item.path, name: item.name }))
            .filter(item => Boolean(item.path)),
        );
      },
      fail: onFail,
    });
    // #endif
  });
}
/** è·¨ç«¯é€‰æ‹©å›¾ç‰‡ï¼ˆç­¾åå›¾ç‰‡ã€å›¾ç‰‡ç±»é™„件) */
export function chooseBpmImage(count = 1): Promise<BpmPickedFile[]> {
  return new Promise((resolve, reject) => {
    uni.chooseImage({
      count,
      success: result => {
        resolve((result.tempFilePaths || []).map(path => ({ path })));
      },
      fail: reject,
    });
  });
}
src/api/crm.ts
@@ -175,26 +175,3 @@
    });
  });
}
export function uploadCrmFile(accessToken: string, filePath: string) {
  return new Promise<StorageFile>((resolve, reject) =>
    uni.uploadFile({
      url: `${API_BASE_URL}/system/storage-blob/upload`,
      filePath,
      name: 'files',
      header: header(accessToken),
      success: response => {
        try {
          const data = JSON.parse(response.data) as ApiResponse<StorageFile[]>;
          if (data.code === 0 && data.data?.[0]) {
            resolve(data.data[0]);
          } else {
            reject(new Error(data.msg || '文件上传失败'));
          }
        } catch {
          reject(new Error('文件上传响应解析失败'));
        }
      },
      fail: () => reject(new Error('文件上传失败,请检查网络后重试')),
    }),
  );
}
src/api/hrm.ts
@@ -101,40 +101,6 @@
export function clockIn(token: string, data: AttendanceClockRequest) { return request<AttendanceClockResponse>("/hrm/attendance/record/clock-in", { method: "POST", data, header: header(token) }); }
export function clockOut(token: string, data: AttendanceClockRequest) { return request<AttendanceClockResponse>("/hrm/attendance/record/clock-out", { method: "POST", data, header: header(token) }); }
export function uploadHrmFile(token: string, filePath: string) {
  return new Promise<HrmAttachment>((resolve, reject) => {
    uni.uploadFile({
      url: `${API_BASE_URL}/system/storage-blob/upload`,
      filePath,
      name: "files",
      header: header(token),
      formData: { directory: "hrm" },
      success: response => {
        try {
          const result = JSON.parse(response.data) as ApiResponse<HrmAttachment[]>;
          if (result.code === 0 && result.data?.[0]) resolve(result.data[0]);
          else reject(new Error(result.msg || "文件上传失败"));
        } catch {
          reject(new Error("文件上传响应解析失败"));
        }
      },
      fail: () => reject(new Error("文件上传失败,请检查网络后重试"))
    });
  });
}
export function chooseHrmFiles(count = 10): Promise<Array<{ path: string; name?: string }>> {
  return new Promise((resolve, reject) => {
    const complete = (files: Array<{ path: string; name?: string }>) => resolve(files.filter(item => Boolean(item.path)));
    // #ifdef MP-WEIXIN
    uni.chooseMessageFile({ count, type: "file", success: result => complete((result.tempFiles || []).map(item => ({ path: item.path, name: item.name }))), fail: reject });
    // #endif
    // #ifndef MP-WEIXIN
    uni.chooseFile({ count, success: result => complete((result.tempFiles || []).map(item => ({ path: item.path, name: item.name }))), fail: reject });
    // #endif
  });
}
export function getEmployeePage(token: string, params: { pageNo: number; pageSize: number; name?: string; employeeNo?: string; employeeStatus?: number }) { return request<PageResult<Employee>>(`/hrm/employee/page${query(params)}`, { header: header(token) }); }
export function getEmployee(token: string, id: number) { return request<Employee>(`/hrm/employee/get${query({ id })}`, { header: header(token) }); }
export function createEmployee(token: string, data: Employee) { return request<number>("/hrm/employee/create", { method: "POST", data, header: header(token) }); }
src/api/process-design.ts
@@ -698,26 +698,6 @@
export function saveBatchConfig(accessToken: string, data: MesBatchConfig) {
  return request<number>("/mes/md/item-batch-config/save", { method: "POST", data, header: header(accessToken) });
}
export function uploadMesFile(accessToken: string, filePath: string) {
  return new Promise<StorageFile>((resolve, reject) =>
    uni.uploadFile({
      url: `${API_BASE_URL}/system/storage-blob/upload`,
      filePath,
      name: "files",
      header: header(accessToken),
      success: response => {
        try {
          const data = JSON.parse(response.data) as ApiResponse<StorageFile[]>;
          if ((data.code === 0 || data.code === 200) && data.data?.[0]) resolve(data.data[0]);
          else reject(new Error(data.msg || "文件上传失败"));
        } catch {
          reject(new Error("文件上传响应解析失败"));
        }
      },
      fail: () => reject(new Error("文件上传失败,请检查网络后重试")),
    }),
  );
}
export function getProductSipListByItemId(accessToken: string, itemId: number) {
  return request<ProductMedia[]>(`/mes/md/product-sip/list-by-item-id${query({ itemId })}`, { header: header(accessToken) });
}
src/api/product-design.ts
@@ -613,81 +613,3 @@
  type?: string;
  createTime?: number;
}
/**
 * æ–‡ä»¶ä¸Šä¼ ï¼ˆè®¾è®¡èµ„æ–™ blobIds)
 * ä¸Ž web ç«¯ FileUpload ç»„件走同一后端接口 /system/storage-blob/upload
 */
export function uploadPdFile(accessToken: string, filePath: string) {
  return new Promise<StorageAttachment>((resolve, reject) => {
    uni.uploadFile({
      url: `${API_BASE_URL}/system/storage-blob/upload`,
      filePath,
      name: "files",
      header: { Authorization: `Bearer ${accessToken}` },
      formData: { directory: "mes/pd" },
      success: response => {
        try {
          const data = JSON.parse(response.data) as ApiResponse<StorageAttachment[]>;
          if (data.code === 0 && data.data?.[0]) resolve(data.data[0]);
          else reject(new Error(data.msg || "文件上传失败"));
        } catch {
          reject(new Error("文件上传响应解析失败"));
        }
      },
      fail: () => reject(new Error("文件上传失败,请检查网络后重试")),
    });
  });
}
/** æœ¬åœ°é€‰ä¸­çš„待上传文件 */
export interface PickedFile {
  path: string;
  name?: string;
}
/**
 * è·¨ç«¯æ–‡ä»¶é€‰æ‹©ï¼ˆH5 / å¾®ä¿¡å°ç¨‹åº / App ä¸‰ç«¯å…¼å®¹ï¼‰
 *
 * å¹³å°å·®å¼‚(uni-app å®˜æ–¹æ”¯æŒçŸ©é˜µï¼‰ï¼š
 * - H5     ï¼šuni.chooseFile å¯ç”¨ï¼ˆæµè§ˆå™¨æ˜¯ <input type="file">)
 * - å¾®ä¿¡å°ç¨‹åºï¼šuni.chooseFile **不可用**;需用 uni.chooseMessageFile ä»ŽèŠå¤©è®°å½•选文件
 * - App    ï¼šuni.chooseFile å¯ç”¨
 *
 * å› æ­¤è¿™é‡Œç”¨æ¡ä»¶ç¼–译分流,统一返回 { path, name }[],调用方无需关心平台。
 */
export function choosePdFile(count = 9): Promise<PickedFile[]> {
  return new Promise((resolve, reject) => {
    const onSuccess = (files: PickedFile[]) => resolve(files);
    const onFail = (error: unknown) => reject(error);
    // #ifdef MP-WEIXIN
    uni.chooseMessageFile({
      count,
      type: "file",
      success: result => {
        onSuccess(
          (result.tempFiles || [])
            .map(item => ({ path: item.path, name: item.name }))
            .filter(item => Boolean(item.path)),
        );
      },
      fail: onFail,
    });
    // #endif
    // #ifndef MP-WEIXIN
    uni.chooseFile({
      count,
      success: result => {
        onSuccess(
          (result.tempFiles || [])
            .map(item => ({ path: item.path, name: item.name }))
            .filter(item => Boolean(item.path)),
        );
      },
      fail: onFail,
    });
    // #endif
  });
}
src/api/srm.ts
@@ -20,24 +20,6 @@
export const createTenderProject = (token: string, data: TenderProject) => request<number>('/srm/tender-project/create', options(token, 'POST', data));
export const updateTenderProject = (token: string, data: TenderProject) => request<boolean>('/srm/tender-project/update', options(token, 'PUT', data));
export const updateTenderProjectInfo = (token: string, data: Pick<TenderProject, 'id' | 'tenderDesc' | 'qualificationRequirements' | 'blobIds'>) => request<boolean>('/srm/tender-project/update-info', options(token, 'PUT', data));
export function uploadTenderFile(accessToken: string, filePath: string) {
  return new Promise<StorageAttachment>((resolve, reject) => {
    uni.uploadFile({
      url: `${API_BASE_URL}/system/storage-blob/upload`,
      filePath,
      name: 'files',
      header: authHeader(accessToken),
      success: response => {
        try {
          const data = JSON.parse(response.data) as ApiResponse<StorageAttachment[]>;
          if (data.code === 0 && data.data?.[0]) resolve(data.data[0]);
          else reject(new Error(data.msg || '文件上传失败'));
        } catch { reject(new Error('文件上传响应解析失败')); }
      },
      fail: () => reject(new Error('文件上传失败,请检查网络后重试')),
    });
  });
}
export const deleteTenderProject = (token: string, id: number) => request<boolean>(`/srm/tender-project/delete${toQuery({ id })}`, options(token, 'DELETE'));
export const publishTender = (token: string, id: number) => request<boolean>(`/srm/tender-project/publish${toQuery({ id })}`, options(token, 'PUT'));
export const closeTender = (token: string, id: number) => request<boolean>(`/srm/tender-project/close${toQuery({ id })}`, options(token, 'PUT'));
src/api/supplier.ts
@@ -154,25 +154,6 @@
export function reviewQuality(accessToken: string, data: ApplyReview) { return request<boolean>('/srm/supplier-apply/review-quality', { method: 'PUT', data, header: authHeader(accessToken) }); }
export function reviewFinance(accessToken: string, data: ApplyReview) { return request<boolean>('/srm/supplier-apply/review-finance', { method: 'PUT', data, header: authHeader(accessToken) }); }
export function uploadCertificateFile(accessToken: string, filePath: string) {
  return new Promise<StorageAttachment>((resolve, reject) => {
    uni.uploadFile({
      url: `${API_BASE_URL}/system/storage-blob/upload`,
      filePath,
      name: 'files',
      header: authHeader(accessToken),
      success: response => {
        try {
          const data = JSON.parse(response.data) as ApiResponse<StorageAttachment[]>;
          if (data.code === 0 && data.data?.[0]) resolve(data.data[0]);
          else reject(new Error(data.msg || '文件上传失败'));
        } catch { reject(new Error('文件上传响应解析失败')); }
      },
      fail: () => reject(new Error('文件上传失败,请检查网络后重试')),
    });
  });
}
export function getCertificatePage(accessToken: string, pageNo: number, supplierName?: string) { return request<PageResult<SupplierCertificateItem>>(`/srm/supplier-certificate/page${pageParams(pageNo, 15, { supplierName })}`, { header: authHeader(accessToken) }); }
export function getCertificate(accessToken: string, id: number) { return request<SupplierCertificateItem>(`/srm/supplier-certificate/get${toQuery({ id })}`, { header: authHeader(accessToken) }); }
export function createCertificate(accessToken: string, data: SupplierCertificateItem) { return request<number>('/srm/supplier-certificate/create', { method: 'POST', data, header: authHeader(accessToken) }); }
src/config/app-routes.ts
@@ -61,7 +61,6 @@
  "bpm/group/index": "/pages/bpm/group/index",
  "bpm/processListener/index": "/pages/bpm/process-listener/index",
  "bpm/processExpression/index": "/pages/bpm/process-expression/index",
  "bpm/businessAttach/index": "/pages/bpm/business-attach/index",
  "bpm/processInstance/manager/index": "/pages/bpm/process-instance/manager/index",
  "bpm/task/manager/index": "/pages/bpm/task/manager/index",
  "ai/model/apiKey/index": "/pages/ai/api-key/index",
src/pages.json
@@ -567,18 +567,6 @@
          }
        },
        {
          "path": "business-attach/index",
          "style": {
            "navigationBarTitleText": "业务附件配置"
          }
        },
        {
          "path": "business-attach/form",
          "style": {
            "navigationBarTitleText": "业务附件配置编辑"
          }
        },
        {
          "path": "process-instance/manager/index",
          "style": {
            "navigationBarTitleText": "流程实例"
src/pages/aftersales/AftersalesAttachmentField.vue
ÎļþÒÑɾ³ý
src/pages/aftersales/problem/form.vue
@@ -4,7 +4,6 @@
import { createProblem, getProblem, updateProblem, type AftersalesProblem } from "../../../api/aftersales";
import { useAuth } from "../../../stores/auth";
import PickerSheet from "../../mes/process-design/components/PickerSheet.vue";
import AftersalesAttachmentField from "../AftersalesAttachmentField.vue";
import { ISSUE_TYPES, PROBLEM_STATUS, ROOT_CAUSES, SEVERITIES, labelOf, toast } from "../shared";
const { hasPermission, state } = useAuth();
@@ -41,7 +40,6 @@
      <view class="field"><text>根因分类</text><view class="picker" @click="!readonly && (rootVisible = true)"><text>{{ ROOT_CAUSES[form.rootCauseCategory || 0] || "请选择" }}</text><text>›</text></view></view>
      <view class="field"><text>物料编码</text><input v-model="form.itemCode" :disabled="readonly" placeholder="来源于工单明细" /></view><view class="field"><text>物料名称</text><input v-model="form.itemName" :disabled="readonly" placeholder="来源于工单明细" /></view><view class="field"><text>批次号</text><input v-model="form.batchCode" :disabled="readonly" placeholder="来源于工单明细" /></view>
      <view class="field column"><text>问题描述</text><textarea v-model="form.description" :disabled="readonly" placeholder="问题现象与背景" /></view><view class="field column"><text>根因分析</text><textarea v-model="form.rootCause" :disabled="readonly" placeholder="缺陷产生的根本原因" /></view><view class="field column"><text>解决方案</text><textarea v-model="form.solution" :disabled="readonly" placeholder="处理措施/SOP,供后续工单参考" /></view><view class="field column"><text>备注</text><textarea v-model="form.remark" :disabled="readonly" placeholder="备注" /></view>
      <AftersalesAttachmentField v-model="form.blobIds" v-model:files="form.attachmentList" :token="state.accessToken" :readonly="readonly" />
    </view>
    <button v-if="canSave" class="save" :loading="submitting" @click="submit">保存</button>
    <PickerSheet v-model:show="issueVisible" title="问题类型" :items="ISSUE_TYPES" :selected-keys="form.issueType ? [form.issueType] : []" :searchable="false" @confirm="keys => form.issueType = Number(keys[0])" />
src/pages/aftersales/return/detail.vue
@@ -11,27 +11,6 @@
async function load() { if (state.accessToken) data.value = await getReturn(state.accessToken, id.value); }
function goTicket() { if (data.value?.ticketId) uni.navigateTo({ url: `/pages/aftersales/ticket/detail?id=${data.value.ticketId}` }); }
function previewReport() {
  if (!data.value?.qualityReportUrl) return;
  const url = data.value.qualityReportUrl;
  if (/\.(png|jpe?g|gif|webp|bmp)(\?.*)?$/i.test(url)) return uni.previewImage({ current: url, urls: [url] });
  // #ifdef H5
  window.open(url, "_blank");
  // #endif
  // #ifndef H5
  uni.showLoading({ title: "打开中…", mask: true });
  uni.downloadFile({
    url,
    success: result => uni.openDocument({
      filePath: result.tempFilePath,
      showMenu: true,
      fail: () => uni.setClipboardData({ data: url, success: () => toast("文件链接已复制") })
    }),
    fail: () => uni.setClipboardData({ data: url, success: () => toast("文件链接已复制") }),
    complete: () => uni.hideLoading()
  });
  // #endif
}
async function approve() {
  if (!state.accessToken || !await confirm("确认审核通过该退货申请?")) return;
  try { await approveReturn(state.accessToken, id.value); toast("审核通过,已同步MES创建退货单"); await load(); }
@@ -60,7 +39,7 @@
        <view class="detail-row"><text>实退金额(元)</text><text>{{ money(data.actualRefundPrice) }}</text></view>
        <view class="detail-row"><text>MES退货单号</text><text>{{ data.mesReturnCode || "--" }}</text></view>
        <view class="detail-row"><text>质检结果</text><text>{{ data.qualityResult || "--" }}</text></view>
        <view class="detail-row"><text>质检报告</text><text :class="{ link: data.qualityReportUrl }" @click="previewReport">{{ data.qualityReportUrl ? "查看报告" : "--" }}</text></view>
        <view class="detail-row"><text>入库时间</text><text>{{ dateTime(data.returnInboundTime) }}</text></view>
        <view class="detail-row"><text>退款时间</text><text>{{ dateTime(data.refundTime) }}</text></view>
        <view class="detail-row"><text>备注</text><text>{{ data.remark || "--" }}</text></view>
src/pages/aftersales/ticket/detail.vue
@@ -9,7 +9,6 @@
import { getHrmUserList, type UserOption } from "../../../api/hrm";
import { useAuth } from "../../../stores/auth";
import PickerSheet from "../../mes/process-design/components/PickerSheet.vue";
import AftersalesAttachmentField from "../AftersalesAttachmentField.vue";
import { ISSUE_TYPES, SEVERITIES, TICKET_STATUS, VISIT_TYPES, confirm, dateTime, labelOf, toast } from "../shared";
const { hasPermission, state } = useAuth();
@@ -131,7 +130,6 @@
        <view class="detail-row"><text>问题描述</text><text>{{ ticket.issueDescription || "--" }}</text></view><view class="detail-row"><text>负责人</text><text>{{ ticket.ownerUserName || "--" }}</text></view><view class="detail-row"><text>处理人</text><text>{{ ticket.handlerUserName || "--" }}</text></view>
        <view class="detail-row"><text>现场签到</text><text>{{ ticket.signInTime ? dateTime(ticket.signInTime) : "未签到" }}</text></view><view class="detail-row"><text>签到地址</text><text>{{ ticket.signInAddress || "--" }}</text></view>
        <view class="detail-row"><text>回访状态</text><text>{{ ticket.visitStatus === 1 ? "已回访通过" : "待回访" }}</text></view><view class="detail-row"><text>问题沉淀</text><text>{{ ticket.problemNo || "--" }}</text></view><view class="detail-row"><text>完结时间</text><text>{{ dateTime(ticket.resolvedTime) }}</text></view><view class="detail-row"><text>关闭时间</text><text>{{ dateTime(ticket.closedTime) }}</text></view><view class="detail-row"><text>备注</text><text>{{ ticket.remark || "--" }}</text></view>
        <AftersalesAttachmentField :model-value="ticket.blobIds" :files="ticket.attachmentList" readonly />
      </view>
      <text class="section-title">工单明细</text>
src/pages/aftersales/ticket/form.vue
@@ -8,7 +8,6 @@
import { getBatchPage, type Batch } from "../../../api/wls";
import { useAuth } from "../../../stores/auth";
import PickerSheet from "../../mes/process-design/components/PickerSheet.vue";
import AftersalesAttachmentField from "../AftersalesAttachmentField.vue";
import { enqueueTicket, isNetworkError } from "../offline-queue";
import { ISSUE_TYPES, SEVERITIES, labelOf, toast } from "../shared";
@@ -177,7 +176,6 @@
      <view class="field"><text>问题分类</text><input v-model="form.issueCategory" placeholder="如外观缺陷、功能故障" /></view>
      <view class="field"><text>严重程度</text><view class="picker" @click="severityVisible = true"><text>{{ labelOf(SEVERITIES, form.severityLevel) }}</text><text>›</text></view></view>
      <view class="field column"><text>备注</text><textarea v-model="form.remark" placeholder="请输入备注" /></view>
      <AftersalesAttachmentField v-model="form.blobIds" v-model:files="form.attachmentList" :token="state.accessToken" />
    </view>
    <text class="section-title">工单明细</text>
    <view class="form-card">
src/pages/bpm/business-attach/form.vue
ÎļþÒÑɾ³ý
src/pages/bpm/business-attach/index.vue
ÎļþÒÑɾ³ý
src/pages/bpm/components/BpmApprovalFlow.vue
@@ -4,7 +4,7 @@
   *
   * ç”¨äºŽã€Œå‘起流程」的节点预测 ä¸Žã€Œå®¡æ‰¹è¯¦æƒ…」的审批流。
   * ç§»åŠ¨ç«¯ä¸æ¸²æŸ“ bpmn-js æµç¨‹å›¾ï¼Œè¿™é‡Œç”¨ç«–向节点流替代,信息与 web ç«¯ä¸€è‡´ï¼š
   * èŠ‚ç‚¹ç±»åž‹å›¾æ ‡ + èŠ‚ç‚¹çŠ¶æ€è‰² + äººå‘˜ + æ—¶é—´ + å®¡æ‰¹æ„è§/附件。
   * èŠ‚ç‚¹ç±»åž‹å›¾æ ‡ + èŠ‚ç‚¹çŠ¶æ€è‰² + äººå‘˜ + æ—¶é—´ + å®¡æ‰¹æ„è§ã€‚
   */
  import {
    CANDIDATE_STRATEGY_START_USER_SELECT,
src/pages/bpm/components/BpmFormRenderer.vue
@@ -153,15 +153,6 @@
    return "";
  }
  /** åŽ†å²æ–‡ä»¶å€¼ä»…ç”¨äºŽåªè¯»å±•ç¤ºï¼ŒApp ç«¯ä¸æä¾›é™„件上传。 */
  function fileList(field: FormField) {
    const value = getValue(field);
    if (!Array.isArray(value)) return [];
    return value.map(item =>
      typeof item === "string" ? { name: item, url: item } : item,
    );
  }
  /** åªè¯»å±•示的文本 */
  function readonlyText(field: FormField) {
    const value = getValue(field);
@@ -306,13 +297,6 @@
      >
        <text class="picker-text">{{ pickerText(field) }}</text>
        <text class="arrow">›</text>
      </view>
      <!-- å›¾ç‰‡ / æ–‡ä»¶ï¼šç”±çˆ¶é¡µé¢æ³¨å…¥ä¸Šä¼ é€»è¾‘,这里展示已上传列表 -->
      <view v-else-if="field.kind === 'image' || field.kind === 'file'" class="upload">
        <view v-for="(file, index) in fileList(field)" :key="index" class="upload-item">
          <text class="upload-name">{{ file.name || `文件${index + 1}` }}</text>
        </view>
      </view>
      <!-- å…œåº•:不支持的组件按文本录入,保证值能填上 -->
src/pages/bpm/form-parser.ts
@@ -351,6 +351,8 @@
/** å•字段校验,返回错误文案(无错返回 "") */
export function validateField(field: FormField, raw: any): string {
  // App ç«¯ä¸æä¾›é™„件上传/下载,图片、文件类字段在 App å†…不参与校验,避免卡住提交
  if (field.kind === "image" || field.kind === "file") return "";
  const value = raw;
  const empty =
    value === undefined ||
@@ -359,9 +361,7 @@
    (Array.isArray(value) && value.length === 0);
  if (field.required && empty) {
    return field.kind === "image" || field.kind === "file"
      ? `请上传${field.title}`
      : `请填写${field.title}`;
    return `请填写${field.title}`;
  }
  if (empty) return "";
src/pages/crm/business/detail.vue
@@ -19,7 +19,6 @@
    transferBusiness,
    updateBusinessStatus,
    updatePermission,
    uploadCrmFile,
    type Business,
    type CrmContact,
    type CrmContract,
@@ -39,7 +38,6 @@
  const activeTab = ref("basic");
  const loading = ref(false);
  const saving = ref(false);
  const uploading = ref(false);
  const followUps = ref<FollowUpRecord[]>([]);
  const contacts = ref<CrmContact[]>([]);
  const contracts = ref<CrmContract[]>([]);
@@ -195,32 +193,6 @@
    uni.showToast({ title: "状态已更新", icon: "success" });
    loadDetail();
  }
  async function chooseFollowImage() {
    if (!state.accessToken || uploading.value) return;
    uni.chooseImage({
      count: 9 - followForm.picUrls.length,
      success: async result => {
        uploading.value = true;
        try {
          for (const path of result.tempFilePaths) {
            const file = await uploadCrmFile(state.accessToken!, path);
            const url = file.previewURL || file.url;
            if (url) followForm.picUrls.push(url);
          }
        } catch (error) {
          uni.showToast({
            title: error instanceof Error ? error.message : "图片上传失败",
            icon: "none",
          });
        } finally {
          uploading.value = false;
        }
      },
    });
  }
  function removeImage(index: number) {
    followForm.picUrls.splice(index, 1);
  }
  async function saveFollow() {
    if (
      !state.accessToken ||
@@ -372,19 +344,8 @@
            </picker>
            <view class="required-label">跟进内容</view><textarea v-model="followForm.content"
                      placeholder="请输入本次跟进内容" ></textarea>
            <view class="upload-head"><text>图片</text><text @click="chooseFollowImage">上传图片</text></view>
            <view v-if="followForm.picUrls.length"
                  class="image-list">
              <view v-for="(url,index) in followForm.picUrls"
                    :key="url"
                    class="image-item">
                <image :src="url"
                       mode="aspectFill"
                       @click="uni.previewImage({urls:followForm.picUrls,current:url})" /><text @click="removeImage(index)">×</text>
              </view>
            </view>
            <button class="primary"
                    :loading="saving || uploading"
                    :loading="saving"
                    @click="saveFollow">保存跟进</button>
          </view>
          <view v-for="item in followUps"
src/pages/crm/clue/detail.vue
@@ -16,7 +16,6 @@
    transformClue,
    transferClue,
    updatePermission,
    uploadCrmFile,
    type Clue,
    type CrmPermission,
    type DictData,
@@ -52,7 +51,6 @@
  const memberUserId = ref<number>();
  const memberLevel = ref(2);
  const saving = ref(false);
  const uploading = ref(false);
  const canUpdate = computed(() => hasPermission("crm:clue:update"));
  const owner = computed(() => clue.value?.ownerUserId === state.user?.id);
  const typeOptions = computed(() =>
@@ -120,32 +118,6 @@
        icon: "none",
      });
    }
  }
  async function chooseFollowImage() {
    if (!state.accessToken || uploading.value) return;
    uni.chooseImage({
      count: 9 - followForm.picUrls.length,
      success: async result => {
        uploading.value = true;
        try {
          for (const path of result.tempFilePaths) {
            const file = await uploadCrmFile(state.accessToken!, path);
            const url = file.previewURL || file.url;
            if (url) followForm.picUrls.push(url);
          }
        } catch (error) {
          uni.showToast({
            title: error instanceof Error ? error.message : "图片上传失败",
            icon: "none",
          });
        } finally {
          uploading.value = false;
        }
      },
    });
  }
  function removeImage(index: number) {
    followForm.picUrls.splice(index, 1);
  }
  async function saveFollow() {
    if (
@@ -317,19 +289,8 @@
            </picker>
            <view class="required-label">跟进内容</view><textarea v-model="followForm.content"
                      placeholder="请输入本次跟进内容" ></textarea>
            <view class="upload-head"><text>图片</text><text @click="chooseFollowImage">上传图片</text></view>
            <view v-if="followForm.picUrls.length"
                  class="image-list">
              <view v-for="(url,index) in followForm.picUrls"
                    :key="url"
                    class="image-item">
                <image :src="url"
                       mode="aspectFill"
                       @click="uni.previewImage({urls:followForm.picUrls,current:url})" /><text @click="removeImage(index)">×</text>
              </view>
            </view>
            <button class="primary"
                    :loading="saving || uploading"
                    :loading="saving"
                    @click="saveFollow">保存跟进</button>
          </view>
          <view v-for="item in followUps"
src/pages/crm/contract/detail.vue
@@ -21,7 +21,6 @@
    submitContract,
    transferContract,
    updatePermission,
    uploadCrmFile,
    type ApproveProcess,
    type CrmContract,
    type CrmPermission,
@@ -42,7 +41,6 @@
  const active = ref("basic");
  const loading = ref(false);
  const saving = ref(false);
  const uploading = ref(false);
  const users = ref<UserSimple[]>([]);
  const dicts = ref<DictData[]>([]);
  const followUps = ref<FollowUpRecord[]>([]);
@@ -236,32 +234,6 @@
        icon: "none",
      });
    }
  }
  async function chooseImage() {
    if (!state.accessToken || uploading.value) return;
    uni.chooseImage({
      count: 9 - follow.picUrls.length,
      success: async result => {
        uploading.value = true;
        try {
          for (const path of result.tempFilePaths) {
            const file = await uploadCrmFile(state.accessToken!, path);
            const url = file.previewURL || file.url;
            if (url) follow.picUrls.push(url);
          }
        } catch (error) {
          uni.showToast({
            title: error instanceof Error ? error.message : "图片上传失败",
            icon: "none",
          });
        } finally {
          uploading.value = false;
        }
      },
    });
  }
  function removeImage(index: number) {
    follow.picUrls.splice(index, 1);
  }
  async function saveFollow() {
    if (
@@ -482,18 +454,8 @@
              <view class="select">{{follow.nextTime||'请选择日期'}}<text>›</text></view>
            </picker><text class="required-label">跟进内容</text><textarea v-model="follow.content"
                      placeholder="请输入本次跟进内容" ></textarea>
            <view class="upload-head"><text>图片</text><text @click="chooseImage">上传图片</text></view>
            <view class="images">
              <view v-for="(url,index) in follow.picUrls"
                    :key="url"
                    class="image">
                <image :src="url"
                       mode="aspectFill"
                       @click="uni.previewImage({urls:follow.picUrls,current:url})" /><text @click="removeImage(index)">×</text>
              </view>
            </view>
            <button class="primary"
                    :loading="saving||uploading"
                    :loading="saving"
                    @click="saveFollow">保存跟进</button>
          </view>
          <view v-for="item in followUps"
src/pages/crm/customer/detail.vue
@@ -27,7 +27,6 @@
    transferCustomer,
    updateCustomerDealStatus,
    updatePermission,
    uploadCrmFile,
    type Business,
    type CrmContact,
    type CrmContract,
@@ -49,7 +48,6 @@
  const activeTab = ref("basic");
  const loading = ref(false);
  const saving = ref(false);
  const uploading = ref(false);
  const contacts = ref<CrmContact[]>([]);
  const businesses = ref<Business[]>([]);
  const contracts = ref<CrmContract[]>([]);
@@ -372,32 +370,6 @@
      });
    }
  }
  async function chooseImage() {
    if (!state.accessToken || uploading.value) return;
    uni.chooseImage({
      count: 9 - follow.picUrls.length,
      success: async result => {
        uploading.value = true;
        try {
          for (const path of result.tempFilePaths) {
            const file = await uploadCrmFile(state.accessToken!, path);
            const url = file.previewURL || file.url;
            if (url) follow.picUrls.push(url);
          }
        } catch (error) {
          uni.showToast({
            title: error instanceof Error ? error.message : "图片上传失败",
            icon: "none",
          });
        } finally {
          uploading.value = false;
        }
      },
    });
  }
  function removeImage(index: number) {
    follow.picUrls.splice(index, 1);
  }
  async function saveFollow() {
    if (
      !state.accessToken ||
@@ -576,18 +548,8 @@
              <view class="select">{{follow.nextTime||'请选择日期'}}<text>›</text></view>
            </picker><text class="required-label">跟进内容</text><textarea v-model="follow.content"
                      placeholder="请输入本次跟进内容" ></textarea>
            <view class="upload-head"><text>图片</text><text @click="chooseImage">上传图片</text></view>
            <view class="images">
              <view v-for="(url,index) in follow.picUrls"
                    :key="url"
                    class="image">
                <image :src="url"
                       mode="aspectFill"
                       @click="uni.previewImage({urls:follow.picUrls,current:url})" /><text @click="removeImage(index)">×</text>
              </view>
            </view>
            <button class="primary"
                    :loading="saving||uploading"
                    :loading="saving"
                    @click="saveFollow">保存跟进</button>
          </view>
          <view v-for="item in followUps"
src/pages/crm/invoice/detail.vue
@@ -1,4 +1,4 @@
<script setup lang="ts">
import { computed, ref } from 'vue'; import { onLoad, onShow } from '@dcloudio/uni-app'; import { getInvoice, submitInvoice, type CrmInvoice } from '../../../api/crm'; import { useAuth } from '../../../stores/auth';
const {hasPermission,state}=useAuth();const id=ref(0),item=ref<CrmInvoice>(),loading=ref(false);const canUpdate=computed(()=>hasPermission('crm:invoice:update'));const money=(x?:number)=>x===undefined?'—':`Â¥${Number(x).toFixed(2)}`;const status=(x?:number)=>({0:'未提交',10:'审批中',20:'审批通过',30:'审批不通过',40:'已取消',50:'已作废'} as Record<number,string>)[x??0]||'未知状态';async function load(){if(!state.accessToken||!id.value)return;loading.value=true;try{item.value=await getInvoice(state.accessToken,id.value);}catch(error){uni.showToast({title:error instanceof Error?error.message:'详情加载失败',icon:'none'});}finally{loading.value=false;}}async function submit(){if(!state.accessToken)return;try{await submitInvoice(state.accessToken,id.value);uni.showToast({title:'已提交审核',icon:'success'});load();}catch(error){uni.showToast({title:error instanceof Error?error.message:'提交失败',icon:'none'});}}onLoad(q=>{id.value=Number(q.id);load();});onShow(load);
</script><template><view class="page"><view v-if="loading" class="state">加载中...</view><template v-else-if="item"><view class="summary"><view><text>{{item.no||'开票申请'}}</text><text>{{item.customerName||'—'}} Â· {{money(item.price)}}</text></view><text>{{status(item.auditStatus)}}</text></view><view class="toolbar"><button v-if="canUpdate" @click="uni.navigateTo({url:`/pages/crm/invoice/form?id=${id}`})">编辑</button><button v-if="canUpdate&&item.auditStatus===0" @click="submit">提交审核</button></view><view class="card"><view v-for="row in [['客户',item.customerName],['合同',item.contract?.no||item.contract?.name],['负责人',item.ownerUserName],['所属部门',item.ownerUserDeptName],['发票类型',item.invoiceType],['发票号码',item.invoiceNo],['发票抬头',item.invoiceTitle],['开票金额',money(item.price)],['开票日期',item.invoiceTime?.slice(0,10)],['剩余可回款金额',money(item.remainingInvoicePrice)],['创建人',item.creatorName],['创建时间',item.createTime?.replace('T',' ').slice(0,16)]]" :key="row[0]" class="row"><text>{{row[0]}}</text><text>{{row[1]??'—'}}</text></view><view class="remark"><text>备注</text><text>{{item.remark||'—'}}</text></view><view class="remark"><text>发票附件</text><text v-if="item.attachmentList?.length">{{item.attachmentList.map(x=>x.name||'附件').join('、')}}</text><text v-else>未上传</text></view></view></template><view v-else class="state">暂无开票申请数据</view></view></template><style lang="scss" scoped>.page{min-height:100vh;padding:24rpx 30rpx;background:#f6f7f9}.summary{display:flex;align-items:center;justify-content:space-between;padding:26rpx;color:#fff;background:var(--color-primary, #1769d8);border-radius:16rpx}.summary text{display:block}.summary view text:first-child{font-size:32rpx;font-weight:600}.summary view text:last-child{margin-top:8rpx;font-size:24rpx;color:#dbeaff}.summary>text{padding:5rpx 10rpx;font-size:21rpx;background:rgba(255,255,255,.18);border-radius:8rpx}.toolbar{display:flex;justify-content:flex-end;gap:16rpx;padding:18rpx 0}.toolbar button{margin:0;color:var(--color-primary, #1769d8);font-size:24rpx;background:var(--color-primary-soft, #f5f8ff);border:1rpx solid var(--color-primary-border, #8ab3ee)}.toolbar button::after{border:0}.card{background:#fff;border-radius:16rpx}.row{display:flex;justify-content:space-between;gap:24rpx;min-height:88rpx;align-items:center;padding:0 24rpx;border-bottom:1rpx solid #eef1f5}.row text:first-child,.remark>text:first-child{color:#718198;font-size:26rpx}.row text:last-child{color:#263a54;font-size:26rpx;text-align:right}.remark{padding:20rpx 24rpx;border-bottom:1rpx solid #eef1f5}.remark text{display:block}.remark text:last-child{margin-top:12rpx;color:#263a54;font-size:26rpx;line-height:1.6}.state{padding:80rpx 20rpx;color:#8795a8;font-size:26rpx;text-align:center}</style>
</script><template><view class="page"><view v-if="loading" class="state">加载中...</view><template v-else-if="item"><view class="summary"><view><text>{{item.no||'开票申请'}}</text><text>{{item.customerName||'—'}} Â· {{money(item.price)}}</text></view><text>{{status(item.auditStatus)}}</text></view><view class="toolbar"><button v-if="canUpdate" @click="uni.navigateTo({url:`/pages/crm/invoice/form?id=${id}`})">编辑</button><button v-if="canUpdate&&item.auditStatus===0" @click="submit">提交审核</button></view><view class="card"><view v-for="row in [['客户',item.customerName],['合同',item.contract?.no||item.contract?.name],['负责人',item.ownerUserName],['所属部门',item.ownerUserDeptName],['发票类型',item.invoiceType],['发票号码',item.invoiceNo],['发票抬头',item.invoiceTitle],['开票金额',money(item.price)],['开票日期',item.invoiceTime?.slice(0,10)],['剩余可回款金额',money(item.remainingInvoicePrice)],['创建人',item.creatorName],['创建时间',item.createTime?.replace('T',' ').slice(0,16)]]" :key="row[0]" class="row"><text>{{row[0]}}</text><text>{{row[1]??'—'}}</text></view><view class="remark"><text>备注</text><text>{{item.remark||'—'}}</text></view></view></template><view v-else class="state">暂无开票申请数据</view></view></template><style lang="scss" scoped>.page{min-height:100vh;padding:24rpx 30rpx;background:#f6f7f9}.summary{display:flex;align-items:center;justify-content:space-between;padding:26rpx;color:#fff;background:var(--color-primary, #1769d8);border-radius:16rpx}.summary text{display:block}.summary view text:first-child{font-size:32rpx;font-weight:600}.summary view text:last-child{margin-top:8rpx;font-size:24rpx;color:#dbeaff}.summary>text{padding:5rpx 10rpx;font-size:21rpx;background:rgba(255,255,255,.18);border-radius:8rpx}.toolbar{display:flex;justify-content:flex-end;gap:16rpx;padding:18rpx 0}.toolbar button{margin:0;color:var(--color-primary, #1769d8);font-size:24rpx;background:var(--color-primary-soft, #f5f8ff);border:1rpx solid var(--color-primary-border, #8ab3ee)}.toolbar button::after{border:0}.card{background:#fff;border-radius:16rpx}.row{display:flex;justify-content:space-between;gap:24rpx;min-height:88rpx;align-items:center;padding:0 24rpx;border-bottom:1rpx solid #eef1f5}.row text:first-child,.remark>text:first-child{color:#718198;font-size:26rpx}.row text:last-child{color:#263a54;font-size:26rpx;text-align:right}.remark{padding:20rpx 24rpx;border-bottom:1rpx solid #eef1f5}.remark text{display:block}.remark text:last-child{margin-top:12rpx;color:#263a54;font-size:26rpx;line-height:1.6}.state{padding:80rpx 20rpx;color:#8795a8;font-size:26rpx;text-align:center}</style>
src/pages/crm/invoice/index.vue
@@ -130,7 +130,7 @@
                class="row"
                @click="goDetail(item.id)">
            <view class="head"><text class="name">{{item.no||'待生成开票编号'}}</text><text :class="['tag', statusClass(item.auditStatus)]">{{status(item.auditStatus)}}</text></view>
            <view class="grid"><text>客户:{{item.customerName||'—'}}</text><text>合同:{{item.contract?.no||item.contract?.name||'—'}}</text><text>发票类型:{{item.invoiceType===undefined?'—':item.invoiceType}}</text><text>发票号码:{{item.invoiceNo||'—'}}</text><text>发票抬头:{{item.invoiceTitle||'—'}}</text><text>开票金额:{{money(item.price)}}</text><text>开票日期:{{item.invoiceTime?.slice(0,10)||'—'}}</text><text>剩余可开票:{{money(item.remainingInvoicePrice)}}</text><text>负责人:{{item.ownerUserName||'—'}}</text><text>所属部门:{{item.ownerUserDeptName||'—'}}</text><text>创建人:{{item.creatorName||'—'}}</text><text>创建时间:{{item.createTime?.replace('T',' ').slice(0,16)||'—'}}</text><text>更新时间:{{item.updateTime?.replace('T',' ').slice(0,16)||'—'}}</text><text>附件:{{item.hasAttachment?'已上传':'未上传'}}</text><text class="full">备注:{{item.remark||'—'}}</text></view>
            <view class="grid"><text>客户:{{item.customerName||'—'}}</text><text>合同:{{item.contract?.no||item.contract?.name||'—'}}</text><text>发票类型:{{item.invoiceType===undefined?'—':item.invoiceType}}</text><text>发票号码:{{item.invoiceNo||'—'}}</text><text>发票抬头:{{item.invoiceTitle||'—'}}</text><text>开票金额:{{money(item.price)}}</text><text>开票日期:{{item.invoiceTime?.slice(0,10)||'—'}}</text><text>剩余可开票:{{money(item.remainingInvoicePrice)}}</text><text>负责人:{{item.ownerUserName||'—'}}</text><text>所属部门:{{item.ownerUserDeptName||'—'}}</text><text>创建人:{{item.creatorName||'—'}}</text><text>创建时间:{{item.createTime?.replace('T',' ').slice(0,16)||'—'}}</text><text>更新时间:{{item.updateTime?.replace('T',' ').slice(0,16)||'—'}}</text><text class="full">备注:{{item.remark||'—'}}</text></view>
            <view class="actions"><button v-if="canUpdate"
                      @click.stop="goForm(item.id)">编辑</button><button v-if="canUpdate&&item.auditStatus===0"
                      @click.stop="submit(item)">提交审核</button><button v-if="canDelete"
src/pages/crm/sale-quotation/index.vue
@@ -133,7 +133,7 @@
            <view class="head"><text class="name">{{x.name}}</text><text :class="['tag', quoteStatusClass(x.status)]">{{quoteStatusText(x.status)}}</text></view>
            <view class="grid"><text>报价单号:{{x.no||'—'}}</text><text>客户:{{x.customerName||'—'}}</text><text>商机:{{x.businessName||'—'}}</text><text>联系人:<text v-if="x.contactId"
                      class="link"
                      @click.stop="goContact(x.contactId)">{{x.contactName||'—'}}</text><text v-else>{{x.contactName||'—'}}</text></text><text>报价金额:{{money(x.totalPrice)}}</text><text>产品总价:{{money(x.totalProductPrice)}}</text><text>整单折扣:{{x.discountPercent===undefined?'—':`${x.discountPercent}%`}}</text><text>税率:{{x.taxRate===undefined?'—':`${x.taxRate}%`}}</text><text>报价日期:{{x.quotationTime?.slice(0,10)||'—'}}</text><text>有效期至:{{x.validUntil?.slice(0,10)||'—'}}</text><text>负责人:{{x.ownerUserName||'—'}}</text><text>创建人:{{x.creatorName||'—'}}</text><text>创建时间:{{x.createTime?.replace('T',' ').slice(0,16)||'—'}}</text><text>更新时间:{{x.updateTime?.replace('T',' ').slice(0,16)||'—'}}</text><text>关联合同:{{x.contractNo||'—'}}</text><text>附件:{{x.attachmentList?.length?'已上传':'未上传'}}</text><text class="full">备注:{{x.remark||'—'}}</text></view>
                      @click.stop="goContact(x.contactId)">{{x.contactName||'—'}}</text><text v-else>{{x.contactName||'—'}}</text></text><text>报价金额:{{money(x.totalPrice)}}</text><text>产品总价:{{money(x.totalProductPrice)}}</text><text>整单折扣:{{x.discountPercent===undefined?'—':`${x.discountPercent}%`}}</text><text>税率:{{x.taxRate===undefined?'—':`${x.taxRate}%`}}</text><text>报价日期:{{x.quotationTime?.slice(0,10)||'—'}}</text><text>有效期至:{{x.validUntil?.slice(0,10)||'—'}}</text><text>负责人:{{x.ownerUserName||'—'}}</text><text>创建人:{{x.creatorName||'—'}}</text><text>创建时间:{{x.createTime?.replace('T',' ').slice(0,16)||'—'}}</text><text>更新时间:{{x.updateTime?.replace('T',' ').slice(0,16)||'—'}}</text><text>关联合同:{{x.contractNo||'—'}}</text><text class="full">备注:{{x.remark||'—'}}</text></view>
            <view class="actions"><button v-if="canUpdate&&x.status===0"
                      @click.stop="form(x.id)">编辑</button><button v-if="canUpdate&&x.status===0&&!x.contractId"
                      @click.stop="convert(x)">转合同</button><button v-if="canDelete&&x.status===0"
src/pages/hrm/HrmAttachmentField.vue
ÎļþÒÑɾ³ý
src/pages/hrm/attendance-exception/form.vue
@@ -1,6 +1,6 @@
<script setup lang="ts">
import{computed,ref}from"vue";import{onLoad}from"@dcloudio/uni-app";import{createAttendanceException,getAttendanceException,getHrmUserList,type AttendanceException,type UserOption}from"../../../api/hrm";import{useAuth}from"../../../stores/auth";import PickerSheet from"../../mes/process-design/components/PickerSheet.vue";import HrmAttachmentField from"../HrmAttachmentField.vue";import{toast}from"../shared";
import{computed,ref}from"vue";import{onLoad}from"@dcloudio/uni-app";import{createAttendanceException,getAttendanceException,getHrmUserList,type AttendanceException,type UserOption}from"../../../api/hrm";import{useAuth}from"../../../stores/auth";import PickerSheet from"../../mes/process-design/components/PickerSheet.vue";import{toast}from"../shared";
const{state}=useAuth();const form=ref<AttendanceException>({exceptionType:1,blobIds:[]}),users=ref<UserOption[]>([]),userVisible=ref(false),typeVisible=ref(false),readonly=ref(false),submitting=ref(false);const types=[{key:1,label:"补签到"},{key:2,label:"补签退"},{key:3,label:"迟到申诉"},{key:4,label:"早退申诉"},{key:5,label:"缺勤申诉"},{key:6,label:"其他"}];const userName=computed(()=>users.value.find(x=>x.id===form.value.userId)?.nickname||form.value.userName||"请选择员工");
async function save(){if(!state.accessToken||readonly.value||!form.value.userId||!form.value.date||!form.value.reason)return toast("请完整填写员工、日期和申请原因");submitting.value=true;try{await createAttendanceException(state.accessToken,form.value);toast("保存成功");setTimeout(()=>uni.navigateBack(),400);}catch(e){toast(e instanceof Error?e.message:"保存失败");}finally{submitting.value=false;}}
onLoad(async q=>{if(!state.accessToken)return;readonly.value=q?.detail==='1';try{users.value=await getHrmUserList(state.accessToken);if(q?.id)form.value=await getAttendanceException(state.accessToken,Number(q.id));}catch(e){toast(e instanceof Error?e.message:"加载失败");}});
</script><template><view class="page"><view class="form-card"><view class="field"><text class="required">申请人</text><view class="picker" @click="!readonly&&(userVisible=true)"><text>{{userName}}</text><text>›</text></view></view><view class="field"><text class="required">异常日期</text><input v-model="form.date" :disabled="readonly" placeholder="YYYY-MM-DD"/></view><view class="field"><text>异常类型</text><view class="picker" @click="!readonly&&(typeVisible=true)"><text>{{types.find(x=>x.key===form.exceptionType)?.label||'请选择'}}</text><text>›</text></view></view><view class="field column"><text class="required">申请原因</text><textarea v-model="form.reason" :disabled="readonly" placeholder="请输入原因"/></view><view class="field column"><text>备注</text><textarea v-model="form.remark" :disabled="readonly"/></view><HrmAttachmentField v-model="form.blobIds" v-model:files="form.attachmentList" :token="state.accessToken" :readonly="readonly"/></view><button v-if="!readonly" class="save" :loading="submitting" @click="save">保存草稿</button><PickerSheet v-model:show="userVisible" title="选择员工" :items="users.map(x=>({key:x.id,label:x.nickname||x.username||String(x.id),sub:x.deptName}))" :selected-keys="form.userId?[form.userId]:[]" @confirm="k=>form.userId=Number(k[0])"/><PickerSheet v-model:show="typeVisible" title="异常类型" :items="types" :selected-keys="form.exceptionType?[form.exceptionType]:[]" :searchable="false" @confirm="k=>form.exceptionType=Number(k[0])"/></view></template><style scoped lang="scss">@import "../form.scss";.field input{text-align:right}</style>
</script><template><view class="page"><view class="form-card"><view class="field"><text class="required">申请人</text><view class="picker" @click="!readonly&&(userVisible=true)"><text>{{userName}}</text><text>›</text></view></view><view class="field"><text class="required">异常日期</text><input v-model="form.date" :disabled="readonly" placeholder="YYYY-MM-DD"/></view><view class="field"><text>异常类型</text><view class="picker" @click="!readonly&&(typeVisible=true)"><text>{{types.find(x=>x.key===form.exceptionType)?.label||'请选择'}}</text><text>›</text></view></view><view class="field column"><text class="required">申请原因</text><textarea v-model="form.reason" :disabled="readonly" placeholder="请输入原因"/></view><view class="field column"><text>备注</text><textarea v-model="form.remark" :disabled="readonly"/></view></view><button v-if="!readonly" class="save" :loading="submitting" @click="save">保存草稿</button><PickerSheet v-model:show="userVisible" title="选择员工" :items="users.map(x=>({key:x.id,label:x.nickname||x.username||String(x.id),sub:x.deptName}))" :selected-keys="form.userId?[form.userId]:[]" @confirm="k=>form.userId=Number(k[0])"/><PickerSheet v-model:show="typeVisible" title="异常类型" :items="types" :selected-keys="form.exceptionType?[form.exceptionType]:[]" :searchable="false" @confirm="k=>form.exceptionType=Number(k[0])"/></view></template><style scoped lang="scss">@import "../form.scss";.field input{text-align:right}</style>
src/pages/hrm/employee-contract/form.vue
@@ -1,11 +1,10 @@
<script setup lang="ts">
import { computed, reactive, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { createContract, getContract, getEmployeeSimpleList, getHrmDicts, updateContract, type EmployeeContract, type EmployeeSimple, type HrmAttachment } from "../../../api/hrm";
import { createContract, getContract, getEmployeeSimpleList, getHrmDicts, updateContract, type EmployeeContract, type EmployeeSimple } from "../../../api/hrm";
import type { DictData } from "../../../api/crm";
import { useAuth } from "../../../stores/auth";
import { HRM_DICT, label, options } from "../shared";
import HrmAttachmentField from "../HrmAttachmentField.vue";
const { hasPermission, state } = useAuth();
const id = ref<number>();
@@ -14,7 +13,6 @@
const saving = ref(false);
const dicts = ref<DictData[]>([]);
const employees = ref<EmployeeSimple[]>([]);
const attachments = ref<HrmAttachment[]>([]);
const form = reactive<EmployeeContract>({ employeeId: undefined, contractType: 1, contractTermType: 1, startDate: "", blobIds: [] });
const readonly = computed(() => mode.value === "detail");
const canSave = computed(() => !readonly.value && (id.value
@@ -64,7 +62,6 @@
    if (id.value) {
      const detail = await getContract(state.accessToken, id.value);
      Object.assign(form, detail, { blobIds: detail.attachmentList?.map(item => item.id) || detail.blobIds || [] });
      attachments.value = detail.attachmentList || [];
    } else if (renewFrom.value) {
      const old = await getContract(state.accessToken, renewFrom.value);
      Object.assign(form, {
@@ -124,7 +121,6 @@
      <view class="field"><text>试用期结束</text><picker v-if="!readonly" mode="date" :value="form.probationEndDate" @change="form.probationEndDate=$event.detail.value"><view class="picker"><text>{{ form.probationEndDate || "请选择日期" }}</text><text>›</text></view></picker><text v-else class="value">{{ form.probationEndDate || "--" }}</text></view>
      <view class="field"><text>试用期工资</text><input v-model.number="form.probationSalary" :disabled="readonly" type="digit" placeholder="请输入金额" /></view>
      <view class="field"><text>转正工资</text><input v-model.number="form.regularSalary" :disabled="readonly" type="digit" placeholder="请输入金额" /></view>
      <HrmAttachmentField v-model="form.blobIds" v-model:files="attachments" :token="state.accessToken" :readonly="readonly" />
      <view class="field column"><text>备注</text><textarea v-model="form.remark" :disabled="readonly" placeholder="请输入备注" /></view>
    </view>
    <button v-if="canSave" class="save" :loading="saving" @click="save">保存</button>
src/pages/hrm/leave/form.vue
@@ -1,11 +1,10 @@
<script setup lang="ts">
import { computed, reactive, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { createLeave, getAutoApprovalPolicy, getHrmDicts, getLeave, getLeaveProcesses, submitLeave, updateLeave, type ApproveProcess, type HrmAttachment, type Leave } from "../../../api/hrm";
import { createLeave, getAutoApprovalPolicy, getHrmDicts, getLeave, getLeaveProcesses, submitLeave, updateLeave, type ApproveProcess, type Leave } from "../../../api/hrm";
import type { DictData } from "../../../api/crm";
import { useAuth } from "../../../stores/auth";
import { HRM_DICT, label, options, setDateTime } from "../shared";
import HrmAttachmentField from "../HrmAttachmentField.vue";
const { hasPermission, state } = useAuth();
const id = ref<number>();
@@ -13,7 +12,6 @@
const saving = ref(false);
const submitting = ref(false);
const dicts = ref<DictData[]>([]);
const attachments = ref<HrmAttachment[]>([]);
const form = reactive<Leave>({ userId: undefined, leaveType: undefined, startTime: "", endTime: "", duration: 0.5, reason: "", blobIds: [] });
const readonly = computed(() => mode.value === "detail");
const canSave = computed(() => !readonly.value && (id.value ? hasPermission("hrm:leave:update") : hasPermission("hrm:leave:create")));
@@ -41,7 +39,6 @@
  if (!id.value) throw new Error("草稿保存失败");
  const latest = await getLeave(state.accessToken, id.value);
  Object.assign(form, latest, { blobIds: latest.attachmentList?.map(item => item.id) || latest.blobIds || [] });
  attachments.value = latest.attachmentList || [];
  return latest;
}
@@ -115,7 +112,6 @@
        return setTimeout(() => uni.navigateBack(), 500);
      }
      Object.assign(form, detail, { blobIds: detail.attachmentList?.map(item => item.id) || detail.blobIds || [] });
      attachments.value = detail.attachmentList || [];
    }
    else { form.userId = state.user?.id; form.userName = state.user?.nickname || state.user?.username; form.deptName = state.user?.dept?.name; }
  } catch (error) {
@@ -156,7 +152,6 @@
      </view>
      <view class="field"><text>请假时长 <text class="required">*</text></text><input v-model.number="form.duration" :disabled="readonly" type="digit" placeholder="单位:天" /></view>
      <view class="field column"><text>请假原因 <text class="required">*</text></text><textarea v-model="form.reason" :disabled="readonly" maxlength="500" placeholder="请输入请假原因" /></view>
      <HrmAttachmentField class="leave-attachment" v-model="form.blobIds" v-model:files="attachments" :token="state.accessToken" :readonly="readonly" />
      <view class="field column"><text>备注</text><textarea v-model="form.remark" :disabled="readonly" placeholder="请输入备注" /></view>
    </view>
    <view v-if="canSave" class="form-actions">
src/pages/hrm/resignation/form.vue
@@ -1,17 +1,16 @@
<script setup lang="ts">
import { computed, reactive, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { createResignation, getEmployeeSimpleList, getHrmDicts, getResignation, updateResignation, type EmployeeSimple, type HrmAttachment, type Resignation } from "../../../api/hrm";
import { createResignation, getEmployeeSimpleList, getHrmDicts, getResignation, updateResignation, type EmployeeSimple, type Resignation } from "../../../api/hrm";
import type { DictData } from "../../../api/crm";
import { useAuth } from "../../../stores/auth";
import { HRM_DICT, label, options } from "../shared";
import HrmAttachmentField from "../HrmAttachmentField.vue";
import PickerSheet from "../../mes/process-design/components/PickerSheet.vue";
const { hasPermission, state } = useAuth();
const id = ref<number>(); const mode = ref("edit"); const saving = ref(false);
const handoverVisible = ref(false);
const dicts = ref<DictData[]>([]); const employees = ref<EmployeeSimple[]>([]); const attachments = ref<HrmAttachment[]>([]);
const dicts = ref<DictData[]>([]); const employees = ref<EmployeeSimple[]>([]);
const form = reactive<Resignation>({ userId: undefined, resignationType: undefined, expectedDate: "", reason: "", handoverUserId: undefined, handoverRemark: "", blobIds: [], remark: "" });
const readonly = computed(() => mode.value === "detail");
const canSave = computed(() => !readonly.value && (id.value ? hasPermission("hrm:resignation:update") : hasPermission("hrm:resignation:create")));
@@ -41,7 +40,6 @@
        return setTimeout(() => uni.navigateBack(), 500);
      }
      Object.assign(form, detail, { blobIds: detail.attachmentList?.map(item => item.id) || detail.blobIds || [] });
      attachments.value = detail.attachmentList || [];
    }
    else { form.userId = state.user?.id; form.userName = state.user?.nickname || state.user?.username; form.deptName = state.user?.dept?.name; }
  } catch (cause) { uni.showToast({ title: cause instanceof Error ? cause.message : "数据加载失败", icon: "none" }); }
@@ -55,7 +53,6 @@
  <view class="field"><text>交接人</text><view v-if="!readonly" class="picker" @click="handoverVisible=true"><text>{{ handoverText }}</text><text>›</text></view><text v-else class="value">{{ handoverText }}</text></view>
  <view class="field column"><text>离职原因 <text class="required">*</text></text><textarea v-model="form.reason" :disabled="readonly" maxlength="500" placeholder="请输入离职原因" /></view>
  <view class="field column"><text>交接说明</text><textarea v-model="form.handoverRemark" :disabled="readonly" maxlength="500" placeholder="请输入交接说明" /></view>
  <HrmAttachmentField v-model="form.blobIds" v-model:files="attachments" :token="state.accessToken" :readonly="readonly" />
  <view class="field column"><text>备注</text><textarea v-model="form.remark" :disabled="readonly" placeholder="请输入备注" /></view>
</view><button v-if="canSave" class="save" :loading="saving" @click="save">保存草稿</button><button v-else-if="readonly&&form.status===0&&hasPermission('hrm:resignation:update')" class="save" @click="mode='edit'">编辑</button><PickerSheet v-model:show="handoverVisible" title="选择交接人" :items="employees.map(item=>({key:item.id||0,label:item.name||'--',sub:item.employeeNo}))" :selected-keys="form.handoverUserId?[form.handoverUserId]:[]" search-placeholder="搜索姓名或工号" @confirm="selectHandover" /></view></template>
<style lang="scss" scoped>@import "../form.scss";</style>
src/pages/hrm/transfer/form.vue
@@ -1,16 +1,14 @@
<script setup lang="ts">
import { computed, reactive, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { createTransfer, getHrmDeptList, getHrmDicts, getHrmPostList, getHrmUserList, getTransfer, updateTransfer, type HrmAttachment, type SimpleOption, type Transfer, type UserOption } from "../../../api/hrm";
import { createTransfer, getHrmDeptList, getHrmDicts, getHrmPostList, getHrmUserList, getTransfer, updateTransfer, type SimpleOption, type Transfer, type UserOption } from "../../../api/hrm";
import type { DeptSimple, DictData } from "../../../api/crm";
import { useAuth } from "../../../stores/auth";
import { HRM_DICT, label, options } from "../shared";
import HrmAttachmentField from "../HrmAttachmentField.vue";
const { hasPermission, state } = useAuth();
const id = ref<number>(); const mode = ref("edit"); const saving = ref(false);
const dicts = ref<DictData[]>([]); const users = ref<UserOption[]>([]); const depts = ref<DeptSimple[]>([]); const posts = ref<SimpleOption[]>([]);
const attachments = ref<HrmAttachment[]>([]);
const form = reactive<Transfer>({ userId: undefined, currentDeptId: undefined, currentPostId: undefined, targetDeptId: undefined, targetPostId: undefined, transferType: undefined, effectiveDate: "", reason: "", blobIds: [] });
const readonly = computed(() => mode.value === "detail");
const canSave = computed(() => !readonly.value && (id.value ? hasPermission("hrm:transfer:update") : hasPermission("hrm:transfer:create")));
@@ -46,7 +44,6 @@
        return setTimeout(() => uni.navigateBack(), 500);
      }
      Object.assign(form, detail, { blobIds: detail.attachmentList?.map(item => item.id) || detail.blobIds || [] });
      attachments.value = detail.attachmentList || [];
    }
    else {
      const current = userData.find(item => item.id === state.user?.id);
@@ -67,7 +64,6 @@
    <view class="field"><text>调岗类型 <text class="required">*</text></text><picker v-if="!readonly" :range="typeOptions" range-key="label" @change="form.transferType=Number(typeOptions[$event.detail.value].value)"><view class="picker"><text>{{ label(dicts,HRM_DICT.transferType,form.transferType,"请选择调岗类型") }}</text><text>›</text></view></picker><text v-else class="value">{{ label(dicts,HRM_DICT.transferType,form.transferType) }}</text></view>
    <view class="field"><text>生效日期 <text class="required">*</text></text><picker v-if="!readonly" mode="date" :value="form.effectiveDate" @change="form.effectiveDate=$event.detail.value"><view class="picker"><text>{{ form.effectiveDate || "请选择生效日期" }}</text><text>›</text></view></picker><text v-else class="value">{{ form.effectiveDate || "--" }}</text></view>
    <view class="field column"><text>调岗原因 <text class="required">*</text></text><textarea v-model="form.reason" :disabled="readonly" maxlength="500" placeholder="请输入调岗原因" /></view>
    <HrmAttachmentField v-model="form.blobIds" v-model:files="attachments" :token="state.accessToken" :readonly="readonly" />
    <view class="field column"><text>备注</text><textarea v-model="form.remark" :disabled="readonly" placeholder="请输入备注" /></view>
  </view><button v-if="canSave" class="save" :loading="saving" @click="save">保存草稿</button><button v-else-if="readonly&&form.status===0&&hasPermission('hrm:transfer:update')" class="save" @click="mode='edit'">编辑</button></view>
</template>
src/pages/mes/md/item/form.vue
@@ -19,7 +19,6 @@
    getProductSopListByItemId,
    getProductBomListByItemId,
    saveBatchConfig,
    uploadMesFile,
    updateProductSip,
    updateProductSop,
    updateMesItem,
@@ -42,7 +41,6 @@
  const generating = ref(false);
  const bomLoading = ref(false);
  const batchSaving = ref(false);
  const uploading = ref(false);
  const selectorOpen = ref(false);
  const mediaOpen = ref(false);
  const mediaKind = ref<"sip" | "sop">("sip");
@@ -209,30 +207,6 @@
    uni.showToast({ title: "保存成功", icon: "success" });
    await loadSub();
  }
  function previewMediaImage() {
    if (mediaForm.url) uni.previewImage({ urls: [mediaForm.url], current: mediaForm.url });
  }
  function removeMediaImage() {
    mediaForm.url = undefined;
  }
  async function chooseMediaImage() {
    if (!state.accessToken || uploading.value) return;
    uni.chooseImage({
      count: 1,
      success: async result => {
        uploading.value = true;
        try {
          const file = await uploadMesFile(state.accessToken!, result.tempFilePaths[0]);
          mediaForm.url = file.previewURL || file.url;
          uni.showToast({ title: "上传成功", icon: "success" });
        } catch (error) {
          uni.showToast({ title: error instanceof Error ? error.message : "图片上传失败", icon: "none" });
        } finally {
          uploading.value = false;
        }
      },
    });
  }
  async function removeMedia(kind: "sip" | "sop", row: ProductMedia) {
    if (!state.accessToken || !row.id || !canEditSub.value) return;
    const ok = await new Promise<boolean>(resolve => uni.showModal({ title: "确认删除", content: "确定删除该数据吗?", success: res => resolve(res.confirm) }));
@@ -303,7 +277,7 @@
    </view>
    <view v-if="selectorOpen" class="mask" @click="selectorOpen=false"><view class="modal" @click.stop><view class="modal-title">{{ selectedBom?.id ? '编辑 BOM ç‰©æ–™' : '添加 BOM ç‰©æ–™' }}</view><view class="selector-search"><input v-model="materialKeyword" placeholder="搜索物料名称" @confirm="loadMaterials" /><button @click="loadMaterials">搜索</button></view><view class="selector-layout"><scroll-view class="selector-tree" scroll-y><view v-for="cat in materialCategoryOptions" :key="cat.id || 0" :class="['cat', { active: cat.id === materialTypeId }]" @click="materialTypeId=cat.id;loadMaterials()">{{ cat.name }}</view></scroll-view><scroll-view class="selector-list" scroll-y><view v-for="row in materialRows" :key="row.id" :class="['material', { selected: row.id === bomForm.bomItemId }]" @click="chooseMaterial(row)"><text>{{ row.name }}</text><text>{{ [row.code,row.specification,row.unitMeasureName].filter(Boolean).join(' Â· ') || '--' }}</text></view><view v-if="!materialRows.length" class="state compact">暂无物料</view></scroll-view></view><view class="field"><text>已选物料</text><text class="value">{{ bomForm.bomItemName || '请选择 BOM ç‰©æ–™' }}</text></view><view class="field"><text>用量比例 <text class="required">*</text></text><input :value="String(bomForm.quantity ?? '')" type="digit" placeholder="请输入用量比例" @input="bomForm.quantity=num($event.detail.value)" /></view><view class="field column"><text>备注</text><textarea v-model="bomForm.remark" placeholder="请输入备注" /></view><view class="modal-actions"><button class="ghost" @click="selectorOpen=false">取消</button><button class="primary" @click="saveBom">保存</button></view></view></view>
    <view v-if="mediaOpen" class="mask" @click="mediaOpen=false"><view class="modal" @click.stop><view class="modal-title">{{ selectedMedia?.id ? '编辑' : '新增' }} {{ mediaKind === 'sip' ? 'SIP' : 'SOP' }}</view><view class="field"><text>标题 <text class="required">*</text></text><input v-model="mediaForm.title" placeholder="请输入标题" /></view><view class="field"><text>展示顺序</text><input :value="String(mediaForm.sort ?? 0)" type="number" placeholder="请输入展示顺序" @input="mediaForm.sort=Number($event.detail.value||0)" /></view><view class="field"><text>所属工序</text><picker :range="processes" range-key="name" @change="selectProcess(Number($event.detail.value))"><view class="picker"><text>{{ mediaForm.processName || '请选择所属工序' }}</text><text>›</text></view></picker></view><view class="field column"><view class="upload-head"><text>图片</text><button :loading="uploading" :disabled="uploading" @click="chooseMediaImage">{{ uploading ? '上传中' : '上传图片' }}</button></view><view v-if="mediaForm.url" class="upload-image"><image :src="mediaForm.url" mode="aspectFill" @click="previewMediaImage" /><text @click="removeMediaImage">删除</text></view><text v-else class="upload-empty">暂未上传图片</text></view><view class="field column"><text>内容说明</text><textarea v-model="mediaForm.description" placeholder="请输入详细描述" /></view><view class="field column"><text>备注</text><textarea v-model="mediaForm.remark" placeholder="请输入备注" /></view><view class="modal-actions"><button class="ghost" @click="mediaOpen=false">取消</button><button class="primary" @click="saveMedia">保存</button></view></view></view>
    <view v-if="mediaOpen" class="mask" @click="mediaOpen=false"><view class="modal" @click.stop><view class="modal-title">{{ selectedMedia?.id ? '编辑' : '新增' }} {{ mediaKind === 'sip' ? 'SIP' : 'SOP' }}</view><view class="field"><text>标题 <text class="required">*</text></text><input v-model="mediaForm.title" placeholder="请输入标题" /></view><view class="field"><text>展示顺序</text><input :value="String(mediaForm.sort ?? 0)" type="number" placeholder="请输入展示顺序" @input="mediaForm.sort=Number($event.detail.value||0)" /></view><view class="field"><text>所属工序</text><picker :range="processes" range-key="name" @change="selectProcess(Number($event.detail.value))"><view class="picker"><text>{{ mediaForm.processName || '请选择所属工序' }}</text><text>›</text></view></picker></view><view class="field column"><text>内容说明</text><textarea v-model="mediaForm.description" placeholder="请输入详细描述" /></view><view class="field column"><text>备注</text><textarea v-model="mediaForm.remark" placeholder="请输入备注" /></view><view class="modal-actions"><button class="ghost" @click="mediaOpen=false">取消</button><button class="primary" @click="saveMedia">保存</button></view></view></view>
  </view>
</template>
src/pages/mes/pd/archive/detail.vue
@@ -34,40 +34,6 @@
    return dictLabel(dicts.value, DICT.MES_PD_ARCHIVE_STATUS, status, "--");
  }
  /** é¢„览/下载文件:H5 ç›´æŽ¥æ–°çª—口打开;小程序/App å…ˆä¸‹è½½å†æ‰“开文档 */
  function openFile(url?: string) {
    if (!url) {
      toast("该资料暂无文件");
      return;
    }
    // #ifdef H5
    window.open(url, "_blank");
    // #endif
    // #ifndef H5
    uni.showLoading({ title: "打开中…", mask: true });
    uni.downloadFile({
      url,
      success: res => {
        if (res.statusCode !== 200) {
          toast("文件下载失败");
          return;
        }
        uni.openDocument({
          filePath: res.tempFilePath,
          showMenu: true,
          fail: () => {
            // éƒ¨åˆ†æ ¼å¼ï¼ˆå¦‚ zip)无法直接打开,退回复制链接
            uni.setClipboardData({ data: url, success: () => toast("文件链接已复制") });
          },
        });
      },
      fail: () => {
        uni.setClipboardData({ data: url, success: () => toast("文件链接已复制") });
      },
      complete: () => uni.hideLoading(),
    });
    // #endif
  }
  async function load() {
    if (!state.accessToken || !productCode.value) return;
@@ -172,9 +138,6 @@
                <text>文件名:{{ doc.fileName || '--' }}</text>
                <text>上传人:{{ doc.uploadUserNickname || '--' }}</text>
                <text>上传时间:{{ dateTimeText(doc.uploadTime) }}</text>
              </view>
              <view v-if="doc.fileUrl" class="sub-actions">
                <button @click="openFile(doc.fileUrl)">预览 / ä¸‹è½½</button>
              </view>
            </view>
          </template>
src/pages/mes/pd/project/detail.vue
@@ -57,7 +57,6 @@
  const docForm = ref<PdDocument>({ documentType: undefined, documentName: "", documentVersion: "", blobIds: [], remark: "" });
  const docEditingId = ref<number>();
  const docTypeVisible = ref(false);
  const docAttachments = ref<Array<{ id?: number; name?: string; url?: string }>>([]);
  /* ---------- èµ„料审核子表 ---------- */
  const audits = ref<PdDocumentAudit[]>([]);
@@ -171,7 +170,6 @@
    docForm.value = row
      ? { ...row, blobIds: row.blobIds ?? [] }
      : { documentType: undefined, documentName: "", documentVersion: project.value.version || "", blobIds: [], remark: "" };
    docAttachments.value = (row?.attachmentList ?? []).map(item => ({ id: item.id, name: item.name, url: item.url }));
    docVisible.value = true;
  }
@@ -199,8 +197,6 @@
        ...docForm.value,
        projectId: projectId.value,
        blobIds: docForm.value.blobIds ?? [],
        fileName: docAttachments.value[0]?.name,
        fileUrl: docAttachments.value[0]?.url,
      };
      if (docEditingId.value) {
        await updatePdDocument(state.accessToken, { ...payload, id: docEditingId.value });
@@ -228,41 +224,6 @@
    } catch (error) {
      toast(error instanceof Error ? error.message : "删除失败");
    }
  }
  /** é¢„览资料文件:H5 æ–°çª—口打开;小程序/App ä¸‹è½½åŽè°ƒç”¨ç³»ç»Ÿæ‰“å¼€ */
  function previewDoc(row: PdDocument) {
    if (!row.fileUrl) {
      toast("该资料暂无文件");
      return;
    }
    const url = row.fileUrl;
    // #ifdef H5
    window.open(url, "_blank");
    // #endif
    // #ifndef H5
    uni.showLoading({ title: "打开中…", mask: true });
    uni.downloadFile({
      url,
      success: res => {
        if (res.statusCode !== 200) {
          toast("文件下载失败");
          return;
        }
        uni.openDocument({
          filePath: res.tempFilePath,
          showMenu: true,
          fail: () => {
            uni.setClipboardData({ data: url, success: () => toast("文件链接已复制") });
          },
        });
      },
      fail: () => {
        uni.setClipboardData({ data: url, success: () => toast("文件链接已复制") });
      },
      complete: () => uni.hideLoading(),
    });
    // #endif
  }
  /* ---------- èµ„料审核 ---------- */
@@ -515,7 +476,6 @@
              <text>上传时间:{{ dateTimeText(row.uploadTime) }}</text>
            </view>
            <view class="actions">
              <button v-if="row.fileUrl" @click="previewDoc(row)">预览</button>
              <button v-if="docEditable" @click="openDocForm(row)">编辑</button>
              <button v-if="docEditable" class="danger" @click="removeDoc(row)">删除</button>
            </view>
src/pages/purchase/components/PurchaseDetail.vue
@@ -6,7 +6,6 @@
    type PurchaseKind,
    type PurchaseRecord,
  } from "../../../api/purchase";
  import type { StorageAttachment } from "../../../api/supplier";
  import { useAuth } from "../../../stores/auth";
  const props = defineProps<{ kind: PurchaseKind }>();
@@ -23,12 +22,6 @@
  const status = (value?: number) =>
    ({ 10: "待审核", 15: "审批中", 20: "已审核", 30: "已撤销" }[value || 0] ||
    "--");
  const attachmentName = (attachment: StorageAttachment) =>
    attachment.name || `附件 #${attachment.id}`;
  function preview(attachment: StorageAttachment) {
    if (attachment.url) uni.previewImage({ urls: [attachment.url] });
    else uni.showToast({ title: "附件地址不可用", icon: "none" });
  }
  onLoad(async query => {
    if (!state.accessToken || !query.id) return;
    try {
@@ -104,17 +97,6 @@
          <text v-if="item.remark">{{ item.remark }}</text>
        </view>
      </view>
      <view class="card">
        <text class="title">附件</text>
        <view v-if="record.attachmentList?.length">
          <text v-for="attachment in record.attachmentList"
                :key="attachment.id"
                class="attachment"
                @click="preview(attachment)">{{ attachmentName(attachment) }}</text>
        </view>
        <text v-else
              class="empty">暂无附件</text>
      </view>
    </template>
    <view v-else
          class="state">加载中...</view>
@@ -162,13 +144,7 @@
    display: block;
    margin: 8rpx 0;
  }
  .attachment {
    display: block;
    padding: 18rpx 0;
    border-top: 1rpx solid #f2f3f5;
    color: var(--color-primary, #165dff);
    font-size: 25rpx;
  }
  .empty,
  .state {
    display: block;
src/pages/purchase/invoice/index.vue
@@ -93,9 +93,6 @@
                @click="go('detail',i.id)">
            <view class="card-head">
              <text class="no">{{ i.no || '--' }}</text>
              <text :class="['attachment', { uploaded: i.hasAttachment || i.attachmentList?.length }]">
                {{ i.hasAttachment || i.attachmentList?.length ? '已上传附件' : '未上传附件' }}
              </text>
            </view>
            <view class="primary-info">
              <text>{{ i.supplierName || '--' }}</text>
@@ -190,18 +187,6 @@
    font-weight: 600;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .attachment {
    flex: none;
    padding: 4rpx 10rpx;
    color: #86909c;
    background: #f2f3f5;
    border-radius: 8rpx;
    font-size: 20rpx;
  }
  .attachment.uploaded {
    color: #16805b;
    background: #eaf8f1;
  }
  .primary-info {
    color: #4e5969;
src/pages/sale/order/detail.vue
@@ -37,8 +37,6 @@
  );
  const itemProductPrice = (item: SaleOrderItem) =>
    Number(item.totalProductPrice ?? Number(item.productPrice || 0) * Number(item.count || 0));
  const attachmentName = (attachment: StorageFile) =>
    attachment.name || `附件 #${attachment.id}`;
  async function reload() {
    if (state.accessToken && id.value) record.value = await getSaleOrder(state.accessToken, id.value);
  }
@@ -95,10 +93,6 @@
    } catch (e) {
      uni.showToast({ title: e instanceof Error ? e.message : "操作失败", icon: "none" });
    }
  }
  function preview(attachment: StorageFile) {
    if (attachment.url) uni.previewImage({ urls: [attachment.url] });
    else uni.showToast({ title: "附件地址不可用", icon: "none" });
  }
  onLoad(async query => {
    id.value = query.id ? Number(query.id) : undefined;
@@ -174,17 +168,6 @@
          <text v-if="item.batchCode">出库批号:{{ item.batchCode }}</text>
          <text v-if="item.remark">{{ item.remark }}</text>
        </view>
      </view>
      <view class="card">
        <text class="cardTitle">附件</text>
        <view v-if="record.attachmentList?.length">
          <text v-for="attachment in record.attachmentList"
                :key="attachment.id"
                class="attachment"
                @click="preview(attachment)">{{ attachmentName(attachment) }}</text>
        </view>
        <text v-else
              class="empty">暂无附件</text>
      </view>
    </template>
    <view v-else
src/pages/supplier/certificate/index.vue
@@ -197,12 +197,6 @@
            <text>颁发日期:{{item.validStartDate||'-'}}</text>
            <text>到期日期:{{item.validEndDate||'-'}}</text>
            <text>创建时间:{{item.createTime||'-'}}</text>
            <view class="attachmentList"><text class="attachmentLabel">附件:</text>
              <view v-if="item.attachmentList?.length"
                    class="attachmentLinks"><text v-for="attachment in item.attachmentList"
                      :key="attachment.id"
                      @click="previewAttachment(attachment)">{{attachment.name||`附件${attachment.id}`}}</text></view><text v-else>-</text>
            </view>
            <view class="actions"><button v-if="canUpdate"
                      @click="openEditor(item.id)">编辑</button><button v-if="canDelete"
                      class="danger"