zhangwencui
6 天以前 fdd3687e392462822226fd4e0ffc20c92a1caaba
refactor(app): 移除页面附件上传功能
已修改16个文件
528 ■■■■■ 文件已修改
src/pages/aftersales/AftersalesAttachmentField.vue 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/bpm/components/BpmFormRenderer.vue 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/bpm/process-instance/detail.vue 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/crm/business/detail.vue 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/crm/clue/detail.vue 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/crm/contract/detail.vue 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/crm/customer/detail.vue 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/crm/invoice/form.vue 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/crm/receivable/form.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/hrm/HrmAttachmentField.vue 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/mes/pd/project/detail.vue 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/purchase/components/PurchaseForm.vue 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/sale/order/form.vue 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/srm/tender/form.vue 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/supplier/access/index.vue 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/supplier/certificate/index.vue 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/aftersales/AftersalesAttachmentField.vue
@@ -1,22 +1,7 @@
<script setup lang="ts">
import { ref } from "vue";
import { chooseAftersalesFiles, uploadAftersalesFile, type AftersalesAttachment } from "../../api/aftersales";
const props = withDefaults(defineProps<{ modelValue?: number[]; files?: AftersalesAttachment[]; token?: string | null; readonly?: boolean }>(), { modelValue: () => [], files: () => [], token: null, readonly: false });
const emit = defineEmits<{ "update:modelValue": [value: number[]]; "update:files": [value: AftersalesAttachment[]] }>();
const uploading = ref(false);
async function addFiles() {
  if (!props.token || uploading.value || props.readonly) return;
  const left = 10 - props.modelValue.length;
  if (left <= 0) return uni.showToast({ title: "最多上传 10 个附件", icon: "none" });
  try {
    const picked = await chooseAftersalesFiles(left); uploading.value = true;
    const ids = [...props.modelValue], files = [...props.files];
    for (const file of picked) { const saved = await uploadAftersalesFile(props.token, file.path); ids.push(saved.id); files.push({ ...saved, name: saved.name || file.name }); }
    emit("update:modelValue", ids); emit("update:files", files);
  } catch (error) { if (!String(error).includes("cancel")) uni.showToast({ title: "附件上传失败", icon: "none" }); }
  finally { uploading.value = false; }
}
function remove(index: number) { emit("update:modelValue", props.modelValue.filter((_, i) => i !== index)); emit("update:files", props.files.filter((_, i) => i !== index)); }
import type { AftersalesAttachment } from "../../api/aftersales";
withDefaults(defineProps<{ modelValue?: number[]; files?: AftersalesAttachment[]; token?: string | null; readonly?: boolean }>(), { modelValue: () => [], files: () => [], token: null, readonly: false });
defineEmits<{ "update:modelValue": [value: number[]]; "update:files": [value: AftersalesAttachment[]] }>();
function preview(file: AftersalesAttachment) {
  if (!file.url) return uni.showToast({ title: "附件暂无可访问地址", icon: "none" });
  const url = file.url;
@@ -39,5 +24,5 @@
  // #endif
}
</script>
<template><view class="attachments"><view class="attachment-head"><text>附件</text><button v-if="!readonly" :loading="uploading" @click="addFiles">添加附件</button></view><view v-for="(file,index) in files" :key="file.id" class="file"><text class="file-name" @click="preview(file)">{{ file.name || `附件 ${index+1}` }}</text><text v-if="!readonly" @click="remove(index)">删除</text></view><text v-if="!files.length" class="empty">暂无附件</text></view></template>
<template><view v-if="files.length" class="attachments"><view class="attachment-head"><text>附件</text></view><view v-for="(file,index) in files" :key="file.id" class="file"><text class="file-name" @click="preview(file)">{{ file.name || `附件 ${index+1}` }}</text></view></view></template>
<style scoped lang="scss">.attachments{padding:20rpx 24rpx}.attachment-head,.file{display:flex;align-items:center;justify-content:space-between;gap:20rpx}.attachment-head{color:#33455d;font-size:27rpx}.attachment-head button{width:150rpx;height:56rpx;margin:0;color:var(--color-primary, #1769d8);font-size:23rpx;line-height:54rpx;background:#f3f7fd;border:1rpx solid #b9d1f2}.attachment-head button::after{border:0}.file{padding:14rpx 0;color:#526780;font-size:24rpx}.file-name{min-width:0;flex:1;overflow:hidden;color:var(--color-primary, #1769d8);text-decoration:underline;text-overflow:ellipsis;white-space:nowrap}.file text:last-child{flex:none;color:#cf4d4d}.empty{display:block;margin-top:14rpx;color:#9aa7b8;font-size:24rpx}</style>
src/pages/bpm/components/BpmFormRenderer.vue
@@ -153,23 +153,12 @@
    return "";
  }
  /** 上传后的文件值(数组,元素为 { name, url } 或 url 字符串) */
  const uploading = computed(() => false);
  /** 历史文件值仅用于只读展示,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,
    );
  }
  async function removeFile(field: FormField, index: number) {
    const list = fileList(field);
    const next = list.filter((_, i) => i !== index);
    setValue(
      field.field,
      next.map(item => item.value ?? item.url ?? item),
    );
  }
@@ -323,9 +312,7 @@
      <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>
          <text class="upload-remove" @click="removeFile(field, index)">删除</text>
        </view>
        <text class="upload-tip">{{ field.placeholder }}</text>
      </view>
      <!-- 兜底:不支持的组件按文本录入,保证值能填上 -->
src/pages/bpm/process-instance/detail.vue
@@ -4,7 +4,6 @@
  import { getSimpleDictDataList, type DictData } from "../../../api/crm";
  import {
    approveTask,
    chooseBpmFile,
    copyTask,
    delegateTask,
    getApprovalDetail,
@@ -17,7 +16,6 @@
    signCreateTask,
    signDeleteTask,
    transferTask,
    uploadBpmFile,
    type BpmApprovalDetail,
    type BpmApprovalNode,
    type BpmDept,
@@ -550,43 +548,6 @@
    });
  }
  /* ---------- 附件上传 ---------- */
  const uploading = ref(false);
  async function chooseAttachment() {
    if (!state.accessToken || uploading.value) return;
    let files: Array<{ path: string; name?: string }> = [];
    try {
      files = await chooseBpmFile(9);
    } catch (error) {
      const msg = error instanceof Error ? error.message : "";
      if (msg && !/cancel/i.test(msg)) toast(msg);
      return;
    }
    if (!files.length) return;
    uploading.value = true;
    uni.showLoading({ title: "上传中…", mask: true });
    try {
      for (const file of files) {
        const saved = await uploadBpmFile(state.accessToken, file.path);
        if (saved.url) operationAttachments.value = [...operationAttachments.value, saved.url];
      }
      toast("上传成功");
    } catch (error) {
      toast(error instanceof Error ? error.message : "上传失败");
    } finally {
      uploading.value = false;
      uni.hideLoading();
    }
  }
  const operationAttachments = ref<string[]>([]);
  function removeAttachment(index: number) {
    operationAttachments.value = operationAttachments.value.filter((_, i) => i !== index);
  }
  onLoad(async query => {
    processInstanceId.value = query?.id ? String(query.id) : "";
    taskId.value = query?.taskId ? String(query.taskId) : "";
@@ -811,18 +772,6 @@
            class="modal-textarea"
            placeholder="请输入审批意见"
          />
        </view>
        <!-- 附件 -->
        <view class="modal-field">
          <text class="modal-label">附件</text>
          <view class="attach-list">
            <view v-for="(url, index) in operationAttachments" :key="url" class="attach-item">
              <text class="attach-name">附件 {{ index + 1 }}</text>
              <text class="attach-remove" @click="removeAttachment(index)">删除</text>
            </view>
          </view>
          <button class="attach-btn" :loading="uploading" @click="chooseAttachment">添加附件</button>
        </view>
        <view class="modal-actions">
src/pages/crm/business/detail.vue
@@ -218,36 +218,8 @@
      },
    });
  }
  async function chooseFollowFile() {
    if (!state.accessToken || uploading.value) return;
    uni.chooseFile({
      count: 10 - followForm.blobIds.length,
      success: async result => {
        uploading.value = true;
        try {
          for (const file of result.tempFiles || []) {
            if (!file.path) continue;
            const attachment = await uploadCrmFile(state.accessToken!, file.path);
            followForm.blobIds.push(attachment.id);
            followForm.attachments.push(attachment);
          }
        } 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);
  }
  function removeFile(index: number) {
    followForm.blobIds.splice(index, 1);
    followForm.attachments.splice(index, 1);
  }
  async function saveFollow() {
    if (
@@ -411,10 +383,7 @@
                       @click="uni.previewImage({urls:followForm.picUrls,current:url})" /><text @click="removeImage(index)">×</text>
              </view>
            </view>
            <view class="upload-head"><text>附件</text><text @click="chooseFollowFile">上传附件</text></view>
            <view v-for="(item,index) in followForm.attachments"
                  :key="item.id"
                  class="file-item"><text>{{item.name || `附件 #${item.id}`}}</text><text @click="removeFile(index)">删除</text></view><button class="primary"
            <button class="primary"
                    :loading="saving || uploading"
                    @click="saveFollow">保存跟进</button>
          </view>
src/pages/crm/clue/detail.vue
@@ -144,36 +144,8 @@
      },
    });
  }
  async function chooseFollowFile() {
    if (!state.accessToken || uploading.value) return;
    uni.chooseFile({
      count: 10 - followForm.blobIds.length,
      success: async result => {
        uploading.value = true;
        try {
          for (const file of result.tempFiles || []) {
            if (!file.path) continue;
            const attachment = await uploadCrmFile(state.accessToken!, file.path);
            followForm.blobIds.push(attachment.id);
            followForm.attachments.push(attachment);
          }
        } 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);
  }
  function removeFile(index: number) {
    followForm.blobIds.splice(index, 1);
    followForm.attachments.splice(index, 1);
  }
  async function saveFollow() {
    if (
@@ -356,10 +328,7 @@
                       @click="uni.previewImage({urls:followForm.picUrls,current:url})" /><text @click="removeImage(index)">×</text>
              </view>
            </view>
            <view class="upload-head"><text>附件</text><text @click="chooseFollowFile">上传附件</text></view>
            <view v-for="(item,index) in followForm.attachments"
                  :key="item.id"
                  class="file-item"><text>{{item.name || `附件 #${item.id}`}}</text><text @click="removeFile(index)">删除</text></view><button class="primary"
            <button class="primary"
                    :loading="saving || uploading"
                    @click="saveFollow">保存跟进</button>
          </view>
src/pages/crm/contract/detail.vue
@@ -260,36 +260,8 @@
      },
    });
  }
  async function chooseFile() {
    if (!state.accessToken || uploading.value) return;
    uni.chooseFile({
      count: 10 - follow.blobIds.length,
      success: async result => {
        uploading.value = true;
        try {
          for (const file of result.tempFiles || []) {
            if (!file.path) continue;
            const attachment = await uploadCrmFile(state.accessToken!, file.path);
            follow.blobIds.push(attachment.id);
            follow.attachments.push(attachment);
          }
        } 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);
  }
  function removeFile(index: number) {
    follow.blobIds.splice(index, 1);
    follow.attachments.splice(index, 1);
  }
  async function saveFollow() {
    if (
@@ -520,10 +492,7 @@
                       @click="uni.previewImage({urls:follow.picUrls,current:url})" /><text @click="removeImage(index)">×</text>
              </view>
            </view>
            <view class="upload-head"><text>附件</text><text @click="chooseFile">上传附件</text></view>
            <view v-for="(file,index) in follow.attachments"
                  :key="file.id"
                  class="file"><text>{{file.name||`附件 #${file.id}`}}</text><text @click="removeFile(index)">删除</text></view><button class="primary"
            <button class="primary"
                    :loading="saving||uploading"
                    @click="saveFollow">保存跟进</button>
          </view>
src/pages/crm/customer/detail.vue
@@ -395,36 +395,8 @@
      },
    });
  }
  async function chooseFile() {
    if (!state.accessToken || uploading.value) return;
    uni.chooseFile({
      count: 10 - follow.blobIds.length,
      success: async result => {
        uploading.value = true;
        try {
          for (const file of result.tempFiles || []) {
            if (!file.path) continue;
            const attachment = await uploadCrmFile(state.accessToken!, file.path);
            follow.blobIds.push(attachment.id);
            follow.attachments.push(attachment);
          }
        } 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);
  }
  function removeFile(index: number) {
    follow.blobIds.splice(index, 1);
    follow.attachments.splice(index, 1);
  }
  async function saveFollow() {
    if (
@@ -614,10 +586,7 @@
                       @click="uni.previewImage({urls:follow.picUrls,current:url})" /><text @click="removeImage(index)">×</text>
              </view>
            </view>
            <view class="upload-head"><text>附件</text><text @click="chooseFile">上传附件</text></view>
            <view v-for="(file,index) in follow.attachments"
                  :key="file.id"
                  class="file"><text>{{file.name||`附件 #${file.id}`}}</text><text @click="removeFile(index)">删除</text></view><button class="primary"
            <button class="primary"
                    :loading="saving||uploading"
                    @click="saveFollow">保存跟进</button>
          </view>
src/pages/crm/invoice/form.vue
@@ -1,15 +1,14 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import { createInvoice, getContractPage, getCrmUserList, getCustomerSimpleList, getInvoice, getSimpleDictDataList, updateInvoice, uploadCrmFile, type CrmContract, type CrmCustomerSimple, type CrmInvoice, type DictData, type StorageFile } from '../../../api/crm';
import { createInvoice, getContractPage, getCrmUserList, getCustomerSimpleList, getInvoice, getSimpleDictDataList, updateInvoice, type CrmContract, type CrmCustomerSimple, type CrmInvoice, type DictData } from '../../../api/crm';
import type { UserSimple } from '../../../api/purchase'; import { useAuth } from '../../../stores/auth';
const { state } = useAuth(); const id=ref<number>(),saving=ref(false),uploading=ref(false),customers=ref<CrmCustomerSimple[]>([]),contracts=ref<CrmContract[]>([]),users=ref<UserSimple[]>([]),dicts=ref<DictData[]>([]),attachments=ref<StorageFile[]>([]);
const { state } = useAuth(); const id=ref<number>(),saving=ref(false),customers=ref<CrmCustomerSimple[]>([]),contracts=ref<CrmContract[]>([]),users=ref<UserSimple[]>([]),dicts=ref<DictData[]>([]);
const form=reactive<CrmInvoice>({customerId:undefined,contractId:undefined,ownerUserId:undefined,invoiceType:undefined,invoiceNo:'',invoiceTitle:'',price:undefined,invoiceTime:'',remark:'',blobIds:[]});
const invoiceTypes=computed(()=>dicts.value.filter(x=>x.dictType==='crm_invoice_type'&&x.status!==1)); const label=(items:any[],value?:number,key='name')=>items.find(x=>x.id===value)?.[key]||'请选择';
function selectCustomer(index:number){const x=customers.value[index];form.customerId=x.id;form.customerName=x.name;form.contractId=undefined;form.contract=undefined;} function selectContract(index:number){const x=contracts.value[index];form.contractId=x.id;form.contract={id:x.id,no:x.no,name:x.name};form.customerId=x.customerId;form.customerName=x.customerName;}
async function chooseFile(){if(!state.accessToken||uploading.value)return; uni.chooseFile({count:10-(form.blobIds?.length||0),success:async result=>{uploading.value=true;try{for(const file of result.tempFiles||[]){if(!file.path)continue;const saved=await uploadCrmFile(state.accessToken!,file.path);form.blobIds?.push(saved.id);attachments.value.push(saved);}}catch(error){uni.showToast({title:error instanceof Error?error.message:'附件上传失败',icon:'none'});}finally{uploading.value=false;}}});}
async function save(){if(!state.accessToken||saving.value)return;if(!form.customerId||!form.contractId||!form.ownerUserId||form.invoiceType===undefined||!Number(form.price)||!form.invoiceTime)return uni.showToast({title:'请填写客户、合同、负责人、发票类型、金额和日期',icon:'none'});saving.value=true;try{const data={...form,price:Number(form.price),invoiceTime:`${form.invoiceTime} 00:00:00`};if(id.value)await updateInvoice(state.accessToken,{...data,id:id.value});else await createInvoice(state.accessToken,data);uni.showToast({title:'保存成功',icon:'success'});setTimeout(()=>uni.navigateBack(),500);}catch(error){uni.showToast({title:error instanceof Error?error.message:'保存失败',icon:'none'});}finally{saving.value=false;}}
onLoad(async query=>{id.value=query.id?Number(query.id):undefined;if(!state.accessToken)return;try{const [customerData,contractData,userData,dictData]=await Promise.all([getCustomerSimpleList(state.accessToken),getContractPage(state.accessToken,{pageNo:1,pageSize:100}),getCrmUserList(state.accessToken),getSimpleDictDataList(state.accessToken)]);customers.value=customerData;contracts.value=contractData.list;users.value=userData;dicts.value=dictData;if(id.value){const data=await getInvoice(state.accessToken,id.value);Object.assign(form,data,{invoiceTime:data.invoiceTime?.slice(0,10)||'',blobIds:data.attachmentList?.map(x=>x.id)||data.blobIds||[]});attachments.value=data.attachmentList||[];}else{form.ownerUserId=state.user?.id;form.ownerUserName=state.user?.nickname;}}catch(error){uni.showToast({title:error instanceof Error?error.message:'数据加载失败',icon:'none'});}});
onLoad(async query=>{id.value=query.id?Number(query.id):undefined;if(!state.accessToken)return;try{const [customerData,contractData,userData,dictData]=await Promise.all([getCustomerSimpleList(state.accessToken),getContractPage(state.accessToken,{pageNo:1,pageSize:100}),getCrmUserList(state.accessToken),getSimpleDictDataList(state.accessToken)]);customers.value=customerData;contracts.value=contractData.list;users.value=userData;dicts.value=dictData;if(id.value){const data=await getInvoice(state.accessToken,id.value);Object.assign(form,data,{invoiceTime:data.invoiceTime?.slice(0,10)||'',blobIds:data.attachmentList?.map(x=>x.id)||data.blobIds||[]});}else{form.ownerUserId=state.user?.id;form.ownerUserName=state.user?.nickname;}}catch(error){uni.showToast({title:error instanceof Error?error.message:'数据加载失败',icon:'none'});}});
</script>
<template><view class="page"><view class="card"><view class="section">开票信息</view><view class="field"><text>客户 <text class="required">*</text></text><picker :range="customers" range-key="name" @change="selectCustomer(Number($event.detail.value))"><view class="picker">{{form.customerName||label(customers,form.customerId)}}<text>›</text></view></picker></view><view class="field"><text>合同 <text class="required">*</text></text><picker :range="contracts" range-key="name" @change="selectContract(Number($event.detail.value))"><view class="picker">{{form.contract?.name||form.contract?.no||label(contracts,form.contractId)}}<text>›</text></view></picker></view><view class="field"><text>负责人 <text class="required">*</text></text><picker :disabled="Boolean(id)" :range="users" range-key="nickname" @change="form.ownerUserId=users[Number($event.detail.value)].id;form.ownerUserName=users[Number($event.detail.value)].nickname"><view class="picker">{{form.ownerUserName||label(users,form.ownerUserId,'nickname')}}<text>›</text></view></picker></view><view class="field"><text>发票类型 <text class="required">*</text></text><picker :range="invoiceTypes" range-key="label" @change="form.invoiceType=Number(invoiceTypes[Number($event.detail.value)].value)"><view class="picker">{{invoiceTypes.find(x=>Number(x.value)===form.invoiceType)?.label||'请选择发票类型'}}<text>›</text></view></picker></view><view class="field"><text>发票号码</text><input v-model="form.invoiceNo" placeholder="请输入发票号码"/></view><view class="field"><text>发票抬头</text><input v-model="form.invoiceTitle" placeholder="请输入发票抬头"/></view><view class="field"><text>开票金额 <text class="required">*</text></text><input :value="form.price===undefined?'':String(form.price)" type="digit" placeholder="请输入开票金额" @input="form.price=Number($event.detail.value||0)"/></view><view class="field"><text>开票日期 <text class="required">*</text></text><picker mode="date" :value="form.invoiceTime" @change="form.invoiceTime=$event.detail.value"><view class="picker">{{form.invoiceTime||'请选择日期'}}<text>›</text></view></picker></view><view class="field column"><text>发票附件</text><button class="upload" :loading="uploading" @click="chooseFile">添加附件</button><text v-for="(file,index) in attachments" :key="file.id" class="file">{{file.name||`附件 ${index+1}`}} <text @click="form.blobIds?.splice(index,1);attachments.splice(index,1)">删除</text></text></view><view class="field column"><text>备注</text><textarea v-model="form.remark" placeholder="请输入备注"></textarea></view></view><button class="save" :loading="saving" :disabled="saving" @click="save">保存</button></view></template>
<template><view class="page"><view class="card"><view class="section">开票信息</view><view class="field"><text>客户 <text class="required">*</text></text><picker :range="customers" range-key="name" @change="selectCustomer(Number($event.detail.value))"><view class="picker">{{form.customerName||label(customers,form.customerId)}}<text>›</text></view></picker></view><view class="field"><text>合同 <text class="required">*</text></text><picker :range="contracts" range-key="name" @change="selectContract(Number($event.detail.value))"><view class="picker">{{form.contract?.name||form.contract?.no||label(contracts,form.contractId)}}<text>›</text></view></picker></view><view class="field"><text>负责人 <text class="required">*</text></text><picker :disabled="Boolean(id)" :range="users" range-key="nickname" @change="form.ownerUserId=users[Number($event.detail.value)].id;form.ownerUserName=users[Number($event.detail.value)].nickname"><view class="picker">{{form.ownerUserName||label(users,form.ownerUserId,'nickname')}}<text>›</text></view></picker></view><view class="field"><text>发票类型 <text class="required">*</text></text><picker :range="invoiceTypes" range-key="label" @change="form.invoiceType=Number(invoiceTypes[Number($event.detail.value)].value)"><view class="picker">{{invoiceTypes.find(x=>Number(x.value)===form.invoiceType)?.label||'请选择发票类型'}}<text>›</text></view></picker></view><view class="field"><text>发票号码</text><input v-model="form.invoiceNo" placeholder="请输入发票号码"/></view><view class="field"><text>发票抬头</text><input v-model="form.invoiceTitle" placeholder="请输入发票抬头"/></view><view class="field"><text>开票金额 <text class="required">*</text></text><input :value="form.price===undefined?'':String(form.price)" type="digit" placeholder="请输入开票金额" @input="form.price=Number($event.detail.value||0)"/></view><view class="field"><text>开票日期 <text class="required">*</text></text><picker mode="date" :value="form.invoiceTime" @change="form.invoiceTime=$event.detail.value"><view class="picker">{{form.invoiceTime||'请选择日期'}}<text>›</text></view></picker></view><view class="field column"><text>备注</text><textarea v-model="form.remark" placeholder="请输入备注"></textarea></view></view><button class="save" :loading="saving" :disabled="saving" @click="save">保存</button></view></template>
<style lang="scss" scoped>.page{min-height:100vh;padding:24rpx 30rpx 70rpx;background:#f6f7f9}.card{overflow:hidden;background:#fff;border-radius:16rpx}.section{padding:22rpx 24rpx 12rpx;color:var(--color-primary, #1769d8);font-size:25rpx;font-weight:600;background:#f8fbff}.field{display:flex;align-items:center;min-height:96rpx;padding:0 24rpx;gap:20rpx;border-bottom:1rpx solid #eef1f5}.field>text:first-child{width:190rpx;color:#33455d;font-size:27rpx}.required{color:#e34b4b}.field input,.field picker,.picker{flex:1;color:#1e3049;font-size:27rpx}.picker{display:flex;justify-content:space-between;color:#65758a}.picker text{color:#8092a7;font-size:36rpx}.column{align-items:flex-start;flex-direction:column;padding:20rpx 24rpx}.column>text:first-child{width:auto}.column textarea{width:100%;height:130rpx;font-size:27rpx}.upload{margin:0;color:var(--color-primary, #1769d8);font-size:24rpx;background:var(--color-primary-soft, #f5f8ff);border:1rpx solid var(--color-primary-border, #8ab3ee)}.file{width:100%;color:#65758a!important;font-size:24rpx!important}.file text{float:right;color:#c65050}.save{margin-top:32rpx;color:#fff;background:var(--color-primary, #1769d8);border-radius:12rpx;font-size:30rpx}.save::after,.upload::after{border:0}</style>
src/pages/crm/receivable/form.vue
@@ -27,7 +27,7 @@
const returnTypeLabel = () => returnTypes.value.find(item => Number(item.value) === form.returnType)?.label || "请选择回款方式";
async function loadRelated() { if (!state.accessToken || !form.contractId) { invoices.value = []; plans.value = []; return; } const [invoiceData, planData] = await Promise.all([getInvoiceSimpleList(state.accessToken, form.contractId), form.customerId ? getReceivablePlanSimpleList(state.accessToken, form.customerId, form.contractId) : Promise.resolve([])]); invoices.value = invoiceData; plans.value = planData; }
function selectCustomer(index: number) { const customer = customers.value[index]; form.customerId = customer.id; form.customerName = customer.name; form.contractId = undefined; form.contractNo = undefined; form.invoiceId = undefined; form.planId = undefined; form.returnTime = ""; form.returnType = undefined; form.price = undefined; invoices.value = []; plans.value = []; }
async function selectContract(index: number) { const contract = availableContracts.value[index]; form.contractId = contract.id; form.contractNo = contract.no || contract.name; form.customerId = contract.customerId; form.customerName = contract.customerName; form.invoiceId = undefined; form.planId = undefined; form.returnTime = ""; form.returnType = undefined; form.price = undefined; await loadRelated(); uni.showToast({ title: "请先完成开票并上传发票附件,再进行回款操作", icon: "none", duration: 2400 }); }
async function selectContract(index: number) { const contract = availableContracts.value[index]; form.contractId = contract.id; form.contractNo = contract.no || contract.name; form.customerId = contract.customerId; form.customerName = contract.customerName; form.invoiceId = undefined; form.planId = undefined; form.returnTime = ""; form.returnType = undefined; form.price = undefined; await loadRelated(); uni.showToast({ title: "请先完成开票,再进行回款操作", icon: "none", duration: 2400 }); }
function selectPlan(index: number) { const plan = plans.value[index]; form.planId = plan.id; form.returnTime = plan.returnTime?.slice(0, 10) || ""; form.returnType = plan.returnType; form.price = plan.price; }
async function save() { if (!state.accessToken || saving.value || !editable.value) return; if (!form.customerId || !form.contractId || !form.invoiceId || !form.ownerUserId || !form.returnTime || form.returnType === undefined || !Number(form.price)) return uni.showToast({ title: "请填写客户、合同、关联发票、负责人、回款日期、方式和金额", icon: "none" }); saving.value = true; try { const data = { ...form, returnTime: `${form.returnTime} 00:00:00`, price: Number(form.price) }; if (id.value) await updateReceivable(state.accessToken, { ...data, id: id.value }); else await createReceivable(state.accessToken, data); uni.showToast({ title: "保存成功", icon: "success" }); setTimeout(() => uni.navigateBack(), 500); } catch (error) { uni.showToast({ title: error instanceof Error ? error.message : "保存失败", icon: "none" }); } finally { saving.value = false; } }
onLoad(async query => { id.value = query.id ? Number(query.id) : undefined; const contractId = query.contractId ? Number(query.contractId) : undefined; const customerId = query.customerId ? Number(query.customerId) : undefined; const planId = query.planId ? Number(query.planId) : undefined; if (!state.accessToken) return; try { const [customerData, contractData, userData, dictData] = await Promise.all([getCustomerSimpleList(state.accessToken), getContractPage(state.accessToken, { pageNo: 1, pageSize: 100 }), getCrmUserList(state.accessToken), getSimpleDictDataList(state.accessToken)]); customers.value = customerData; contracts.value = contractData.list; users.value = userData; dicts.value = dictData; if (id.value) { const data = await getReceivable(state.accessToken, id.value); Object.assign(form, data, { returnTime: data.returnTime?.slice(0, 10) || "" }); await loadRelated(); } else { form.customerId = customerId; form.customerName = customers.value.find(item => item.id === customerId)?.name; form.ownerUserId = state.user?.id; form.ownerUserName = state.user?.nickname; if (contractId) { const contract = contracts.value.find(item => item.id === contractId) || await getContract(state.accessToken, contractId); if (!contracts.value.some(item => item.id === contract.id)) contracts.value.push(contract); form.contractId = contract.id; form.contractNo = contract.no || contract.name; form.customerId = contract.customerId; form.customerName = contract.customerName; await loadRelated(); } if (planId && form.customerId && form.contractId) { const plan = plans.value.find(item => item.id === planId); if (plan) selectPlan(plans.value.indexOf(plan)); } } } catch (error) { uni.showToast({ title: error instanceof Error ? error.message : "数据加载失败", icon: "none" }); } });
src/pages/hrm/HrmAttachmentField.vue
@@ -1,63 +1,30 @@
<script setup lang="ts">
import { ref } from "vue";
import { chooseHrmFiles, uploadHrmFile, type HrmAttachment } from "../../api/hrm";
const props = withDefaults(defineProps<{
import type { HrmAttachment } from "../../api/hrm";
withDefaults(defineProps<{
  modelValue?: number[];
  files?: HrmAttachment[];
  token?: string | null;
  readonly?: boolean;
}>(), { modelValue: () => [], files: () => [], token: null, readonly: false });
const emit = defineEmits<{
defineEmits<{
  "update:modelValue": [value: number[]];
  "update:files": [value: HrmAttachment[]];
}>();
const uploading = ref(false);
async function addFiles() {
  if (!props.token || uploading.value || props.readonly) return;
  const remaining = 10 - props.modelValue.length;
  if (remaining <= 0) return uni.showToast({ title: "最多上传 10 个附件", icon: "none" });
  try {
    const picked = await chooseHrmFiles(remaining);
    if (!picked.length) return;
    uploading.value = true;
    const ids = [...props.modelValue];
    const files = [...props.files];
    for (const item of picked) {
      const uploaded = await uploadHrmFile(props.token, item.path);
      ids.push(uploaded.id);
      files.push({ ...uploaded, name: uploaded.name || item.name });
    }
    emit("update:modelValue", ids);
    emit("update:files", files);
  } catch (error) {
    const message = error instanceof Error ? error.message : "附件上传失败";
    if (!String(message).includes("cancel")) uni.showToast({ title: message, icon: "none" });
  } finally {
    uploading.value = false;
  }
}
function remove(index: number) {
  emit("update:modelValue", props.modelValue.filter((_, itemIndex) => itemIndex !== index));
  emit("update:files", props.files.filter((_, itemIndex) => itemIndex !== index));
}
</script>
<template>
  <view class="attachment-field">
  <view v-if="files.length" class="attachment-field">
    <view class="attachment-head">
      <text>附件</text>
      <button v-if="!readonly" :loading="uploading" :disabled="uploading||modelValue.length>=10" @click="addFiles">添加附件</button>
    </view>
    <view v-if="files.length" class="attachment-list">
      <view v-for="(file,index) in files" :key="file.id" class="attachment-item">
        <text>{{ file.name || `附件 ${index + 1}` }}</text>
        <text v-if="!readonly" class="remove" @click="remove(index)">删除</text>
      </view>
    </view>
    <text v-else class="empty">暂无附件</text>
  </view>
</template>
src/pages/mes/pd/project/detail.vue
@@ -4,7 +4,6 @@
  import { getSimpleDictDataList, type DictData } from "../../../../api/crm";
  import {
    archivePdProject,
    choosePdFile,
    createPdDocument,
    deletePdDocument,
    getPdDocumentAuditPage,
@@ -15,13 +14,11 @@
    submitPdProjectApproval,
    updatePdDocument,
    updatePdDocumentAudit,
    uploadPdFile,
    uploadPdProject,
    type PdApproveProcess,
    type PdDocument,
    type PdDocumentAudit,
    type PdProject,
    type PickedFile,
  } from "../../../../api/product-design";
  import { useAuth } from "../../../../stores/auth";
  import PickerSheet from "../../process-design/components/PickerSheet.vue";
@@ -180,47 +177,6 @@
  function onDocTypePicked(keys: Array<number | string>) {
    docForm.value.documentType = keys[0] === undefined || keys[0] === "" ? undefined : String(keys[0]);
  }
  /** 选择文件并上传:走 /system/storage-blob/upload,取回 id 作为 blobIds(三端兼容) */
  async function chooseFile() {
    if (!state.accessToken) return;
    let files: PickedFile[] = [];
    try {
      files = await choosePdFile(9);
    } catch (error) {
      // 用户主动取消不提示;真失败才提示
      const msg = error instanceof Error ? error.message : "";
      if (msg && !/cancel/i.test(msg)) toast(msg);
      return;
    }
    if (!files.length) return;
    uni.showLoading({ title: "上传中…", mask: true });
    try {
      for (const file of files) {
        const attachment = await uploadPdFile(state.accessToken, file.path);
        if (attachment.id) {
          docForm.value.blobIds = [...(docForm.value.blobIds ?? []), attachment.id];
          docAttachments.value = [
            ...docAttachments.value,
            { id: attachment.id, name: attachment.name || file.name, url: attachment.url },
          ];
        }
      }
      toast("上传成功");
    } catch (error) {
      toast(error instanceof Error ? error.message : "上传失败");
    } finally {
      uni.hideLoading();
    }
  }
  function removeAttachment(index: number) {
    const target = docAttachments.value[index];
    docAttachments.value = docAttachments.value.filter((_, i) => i !== index);
    if (target?.id) {
      docForm.value.blobIds = (docForm.value.blobIds ?? []).filter(id => id !== target.id);
    }
  }
  async function submitDoc() {
@@ -630,17 +586,6 @@
          <view class="field">
            <text class="label required">版本</text>
            <input v-model="docForm.documentVersion" placeholder="请输入版本" />
          </view>
          <view class="field column">
            <text class="label">附件</text>
            <view class="files">
              <view v-for="(file, index) in docAttachments" :key="file.id ?? index" class="file">
                <text class="file-name">{{ file.name || `附件${index + 1}` }}</text>
                <text class="file-del" @click="removeAttachment(index)">移除</text>
              </view>
              <text v-if="!docAttachments.length" class="file-empty">暂未上传附件</text>
            </view>
            <button class="upload" @click="chooseFile">选择文件上传</button>
          </view>
          <view class="field column">
            <text class="label">备注</text>
src/pages/purchase/components/PurchaseForm.vue
@@ -19,8 +19,6 @@
  import { getMaterialPage, type MaterialItem } from "../../../api/basic-data";
  import {
    getSupplierSimpleList,
    uploadCertificateFile,
    type StorageAttachment,
    type SupplierItem,
  } from "../../../api/supplier";
  import { useAuth } from "../../../stores/auth";
@@ -188,35 +186,6 @@
    );
    form.totalPrice = total;
    form.paymentPrice = total - (form.discountPrice || 0);
  }
  function chooseAttachment() {
    if (!state.accessToken) return;
    uni.chooseFile({
      count: 1,
      success: async result => {
        const file = result.tempFiles?.[0];
        if (!file?.path) return;
        try {
          const attachment = await uploadCertificateFile(
            state.accessToken!,
            file.path
          );
          form.blobIds = [...(form.blobIds || []), attachment.id];
          form.attachmentList = [...(form.attachmentList || []), attachment];
          uni.showToast({ title: "上传成功", icon: "success" });
        } catch (error) {
          uni.showToast({ title: message(error), icon: "none" });
        }
      },
    });
  }
  function removeAttachment(index: number) {
    form.blobIds?.splice(index, 1);
    form.attachmentList?.splice(index, 1);
  }
  function previewAttachment(attachment: StorageAttachment) {
    if (attachment.url) uni.previewImage({ urls: [attachment.url] });
    else uni.showToast({ title: "附件地址不可用", icon: "none" });
  }
  async function load() {
    if (!state.accessToken) return;
@@ -627,16 +596,6 @@
            class="summary"><text>合计</text>
        <view><text>数量:{{ (form.items || []).reduce((sum, item) => sum + (item.count || 0), 0).toFixed(3) }}</text><text>金额:¥ {{ (form.items || []).reduce((sum, item) => sum + (kind === 'request' ? requestItemAmount(item) : orderItemAmounts(item).totalAmount), 0).toFixed(2) }}</text></view>
      </view>
    </view>
    <view class="card attachments">
      <view class="section"><text>附件</text><button @click="chooseAttachment">上传附件</button></view>
      <view v-if="form.attachmentList?.length">
        <view v-for="(attachment, index) in form.attachmentList"
              :key="attachment.id"
              class="attachment"><text @click="previewAttachment(attachment)">{{ attachment.name || `附件 #${attachment.id}` }}</text><text class="danger"
                @click="removeAttachment(index)">删除</text></view>
      </view><text v-else
            class="empty">暂无附件</text>
    </view>
    <button class="save"
            :loading="saving"
src/pages/sale/order/form.vue
@@ -18,8 +18,6 @@
    getCustomerSimpleList,
    getMaterialSimpleList,
  } from "../../../api/crm";
  import type { StorageFile } from "../../../api/crm";
  import { uploadCertificateFile } from "../../../api/supplier";
  import { useAuth } from "../../../stores/auth";
  import { materialOptionText } from "../../../utils/material";
@@ -168,28 +166,6 @@
    form.items?.splice(index, 1);
    recalc();
  }
  function chooseAttachment() {
    if (!state.accessToken) return;
    uni.chooseFile({
      count: 1,
      success: async result => {
        const file = result.tempFiles?.[0];
        if (!file?.path) return;
        try {
          const attachment = await uploadCertificateFile(state.accessToken!, file.path);
          form.blobIds = [...(form.blobIds || []), attachment.id];
          form.attachmentList = [...(form.attachmentList || []), attachment as StorageFile];
          uni.showToast({ title: "上传成功", icon: "success" });
        } catch (e) {
          uni.showToast({ title: e instanceof Error ? e.message : "上传失败", icon: "none" });
        }
      },
    });
  }
  function removeAttachment(index: number) {
    form.blobIds?.splice(index, 1);
    form.attachmentList?.splice(index, 1);
  }
  async function save() {
    if (!state.accessToken || saving.value) return;
    if (!editable.value)
@@ -284,9 +260,6 @@
                @change="selectUser"><view class="picker">{{ userLabel() }}<text>›</text></view></picker></view>
      <view class="field area"><text>备注</text><textarea v-model="form.remark"
                  placeholder="请输入备注" ></textarea></view>
      <view class="field column"><text>附件</text><view class="attachments"><button @click="chooseAttachment">上传附件</button><text v-for="(file, index) in form.attachmentList"
                  :key="file.id"
                  @click="removeAttachment(index)">{{ file.name || `附件 #${file.id}` }} ×</text></view></view>
    </view>
    <view class="card">
      <view class="sectionHead"><text>销售产品清单</text><button @click="addItem">添加销售产品</button></view>
src/pages/srm/tender/form.vue
@@ -6,8 +6,6 @@
    getTenderProject,
    updateTenderProject,
    updateTenderProjectInfo,
    uploadTenderFile,
    type StorageAttachment,
    type TenderProject,
  } from "../../../api/srm";
  import { useAuth } from "../../../stores/auth";
@@ -43,40 +41,6 @@
  }
  const partialEditable = () =>
    Boolean(id.value && [1, 2].includes(form.tenderStatus || 0));
  async function chooseAttachment() {
    if (!state.accessToken) return;
    uni.chooseFile({
      count: 1,
      extension: ["jpg", "jpeg", "png", "pdf", "doc", "docx"],
      success: async result => {
        const file = result.tempFiles?.[0];
        if (!file?.path) return;
        try {
          const attachment = await uploadTenderFile(
            state.accessToken!,
            file.path
          );
          form.blobIds = [...(form.blobIds || []), attachment.id];
          form.attachmentList = [...(form.attachmentList || []), attachment];
          uni.showToast({ title: "上传成功", icon: "success" });
        } catch (error) {
          uni.showToast({
            title: error instanceof Error ? error.message : "上传失败",
            icon: "none",
          });
        }
      },
    });
  }
  function removeAttachment(index: number) {
    form.blobIds?.splice(index, 1);
    form.attachmentList?.splice(index, 1);
  }
  function previewAttachment(item: StorageAttachment) {
    const url = item.previewURL || item.url;
    if (url) uni.previewImage({ urls: [url] });
    else uni.showToast({ title: "附件地址不可用", icon: "none" });
  }
  async function save() {
    if (!form.tenderNo.trim() || !form.tenderName.trim())
      return uni.showToast({ title: "请填写招标编号和名称", icon: "none" });
@@ -200,12 +164,6 @@
      </template>
      <view class="field column"><text>资质要求</text><textarea v-model="form.qualificationRequirements"
                  placeholder="请输入资质要求" ></textarea></view>
      <view class="attachments">
        <view class="attachmentHead"><text>资质要求附件</text><text @click="chooseAttachment">上传附件</text></view>
        <view v-for="(item,index) in form.attachmentList"
              :key="item.id"
              class="attachment"><text @click="previewAttachment(item)">{{item.name||`附件${index+1}`}}</text><text @click="removeAttachment(index)">删除</text></view>
      </view>
    </view>
    <button class="save"
            :loading="saving"
src/pages/supplier/access/index.vue
@@ -13,8 +13,6 @@
    reviewQuality,
    submitApply,
    updateApply,
    uploadCertificateFile,
    type StorageAttachment,
    type SupplierApplyItem,
    type SupplierItem,
  } from "../../../api/supplier";
@@ -132,40 +130,6 @@
        address: supplier.address || "",
        taxNo: supplier.taxNo || "",
      });
  }
  async function chooseAttachment() {
    if (!state.accessToken) return;
    uni.chooseFile({
      count: 1,
      extension: ["jpg", "jpeg", "png", "pdf", "doc", "docx"],
      success: async result => {
        const file = result.tempFiles?.[0];
        if (!file?.path) return;
        try {
          const attachment = await uploadCertificateFile(
            state.accessToken!,
            file.path
          );
          form.blobIds = [...(form.blobIds || []), attachment.id];
          form.attachmentList = [...(form.attachmentList || []), attachment];
          uni.showToast({ title: "上传成功", icon: "success" });
        } catch (e) {
          uni.showToast({
            title: e instanceof Error ? e.message : "上传失败",
            icon: "none",
          });
        }
      },
    });
  }
  function removeAttachment(index: number) {
    form.blobIds?.splice(index, 1);
    form.attachmentList?.splice(index, 1);
  }
  function previewAttachment(item: StorageAttachment) {
    const url = item.previewURL || item.url;
    if (url) uni.previewImage({ urls: [url] });
    else uni.showToast({ title: "附件地址不可用", icon: "none" });
  }
  async function save() {
    if (!form.supplierId && !form.supplierName.trim())
@@ -329,12 +293,7 @@
                 placeholder="税号" /></view>
        <view class="area"><text>备注</text><textarea v-model="form.remark"
                    placeholder="备注" ></textarea></view>
        <view class="attachments">
          <view class="attachmentHead"><text>准入资料</text><text @click="chooseAttachment">上传附件</text></view>
          <view v-for="(item,index) in form.attachmentList"
                :key="item.id"
                class="attachment"><text @click="previewAttachment(item)">{{item.name||`附件${index+1}`}}</text><text @click="removeAttachment(index)">删除</text></view>
        </view><button class="save"
<button class="save"
                @click="save">保存草稿</button>
      </scroll-view></view>
    <view v-if="reviewOpen"
src/pages/supplier/certificate/index.vue
@@ -9,10 +9,8 @@
    getExpiringCertificates,
    getSupplierSimpleList,
    updateCertificate,
    uploadCertificateFile,
    type SupplierCertificateItem,
    type SupplierItem,
    type StorageAttachment,
  } from "../../../api/supplier";
  import { useAuth } from "../../../stores/auth";
  import { statusToneClass } from "../../../utils/status";
@@ -169,38 +167,6 @@
      });
    }
  }
  function chooseAttachment() {
    if (!state.accessToken) return;
    uni.chooseFile({
      count: 1,
      success: async result => {
        const file = result.tempFiles?.[0];
        if (!file?.path) return;
        try {
          const attachment = await uploadCertificateFile(
            state.accessToken!,
            file.path
          );
          form.blobIds = [...(form.blobIds || []), attachment.id];
          form.attachmentList = [...(form.attachmentList || []), attachment];
          uni.showToast({ title: "上传成功", icon: "success" });
        } catch (e) {
          uni.showToast({
            title: e instanceof Error ? e.message : "上传失败",
            icon: "none",
          });
        }
      },
    });
  }
  function removeAttachment(index: number) {
    form.blobIds?.splice(index, 1);
    form.attachmentList?.splice(index, 1);
  }
  function previewAttachment(item: StorageAttachment) {
    if (item.url) uni.previewImage({ urls: [item.url] });
    else uni.showToast({ title: "附件地址不可用", icon: "none" });
  }
  onShow(() => load(true));
  onPullDownRefresh(async () => {
    await load(true);
@@ -304,12 +270,7 @@
        </view>
        <view class="area"><text>备注</text><textarea v-model="form.remark"
                    placeholder="备注" ></textarea></view>
        <view class="attachments">
          <view class="attachmentHead"><text>证书附件</text><text @click="chooseAttachment">上传附件</text></view>
          <view v-for="(item,index) in form.attachmentList"
                :key="item.id"
                class="attachment"><text @click="previewAttachment(item)">{{item.name||`附件${index+1}`}}</text><text @click="removeAttachment(index)">删除</text></view>
        </view><button class="save"
<button class="save"
                @click="save">保存</button>
      </scroll-view></view>
  </view>