zlwu 4 kuukautta sitten
vanhempi
commit
b896254dff

BIN
bx/bx-backend/app/routers/__pycache__/reimburse.cpython-312.pyc


+ 12 - 4
bx/bx-backend/app/routers/reimburse.py

@@ -17,7 +17,7 @@ from app.services.reimburse import ReimburseService
 from app.services.expense_item import ExpenseItemService
 from app.models.workflow import WorkflowDef
 from app.utils.codegen import generate_apply_code, generate_title
-from app.utils.date_range import parse_create_time_day_bounds
+from app.utils.date_range import parse_create_time_day_bounds, resolve_apply_create_time
 
 router = APIRouter(prefix="/api/reimburses", tags=["报销管理"])
 
@@ -27,8 +27,12 @@ def _build_out_with_details(reimburse: ReimburseApply, db: Session) -> Reimburse
 
 
 @router.get("/generate-title", response_model=ApiResponse[str])
-def get_generated_title(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
-    title = generate_title(db, current_user.real_name or current_user.username)
+def get_generated_title(
+    applyDate: Optional[str] = None,
+    db: Session = Depends(get_db),
+    current_user: User = Depends(get_current_user),
+):
+    title = generate_title(db, current_user.real_name or current_user.username, applyDate)
     return ApiResponse(data=title)
 
 
@@ -197,10 +201,14 @@ def create_reimburse(body: ReimburseApplyCreate, db: Session = Depends(get_db),
             "remark": d.remark,
         })
 
+    apply_date = body.apply_date
     data = {
-        "apply_code": generate_apply_code(db, "BX"),
+        "apply_code": generate_apply_code(db, "BX", apply_date),
         "status": "draft",
         "remark": body.remark,
+        "apply_date": apply_date,
+        "create_time": resolve_apply_create_time(apply_date),
+        "update_time": resolve_apply_create_time(apply_date),
     }
     if body.title:
         data["title"] = body.title

BIN
bx/bx-backend/app/schemas/__pycache__/reimburse.cpython-312.pyc


+ 1 - 0
bx/bx-backend/app/schemas/reimburse.py

@@ -28,6 +28,7 @@ class ReimburseApplyCreate(BaseModel):
     model_config = ConfigDict(populate_by_name=True, alias_generator=to_camel)
 
     title: Optional[str] = None
+    apply_date: Optional[str] = None
     details: List[ReimburseDetailCreate] = Field(..., min_length=1)
     remark: Optional[str] = None
 

BIN
bx/bx-backend/app/services/__pycache__/reimburse.cpython-312.pyc


+ 4 - 1
bx/bx-backend/app/services/reimburse.py

@@ -423,7 +423,10 @@ class ReimburseService:
         from app.models.reimburse import ReimburseDetail
 
         total_amount = sum(d.get("amount", 0) for d in details)
-        title = obj_in.pop("title", None) or generate_title(db, user.real_name or user.username)
+        apply_date = obj_in.pop("apply_date", None)
+        title = obj_in.pop("title", None) or generate_title(
+            db, user.real_name or user.username, apply_date
+        )
         first_detail = details[0] if details else {}
 
         apply_data = {

BIN
bx/bx-backend/app/utils/__pycache__/codegen.cpython-312.pyc


BIN
bx/bx-backend/app/utils/__pycache__/date_range.cpython-312.pyc


+ 20 - 8
bx/bx-backend/app/utils/codegen.py

@@ -5,13 +5,22 @@ from sqlalchemy.orm import Session
 from sqlalchemy import text
 
 
-def generate_apply_code(db: Session, prefix: str) -> str:
+def _date_yyyymmdd(apply_date: str | None = None) -> str:
+    if apply_date and str(apply_date).strip():
+        try:
+            return datetime.strptime(str(apply_date).strip(), "%Y-%m-%d").strftime("%Y%m%d")
+        except ValueError:
+            pass
+    return datetime.now().strftime("%Y%m%d")
+
+
+def generate_apply_code(db: Session, prefix: str, apply_date: str | None = None) -> str:
     """
     Generate a sequential code: {prefix}{YYYYMMDD}{3-digit-sequence}.
     prefix examples: 'BYJ' for advance, 'BX' for reimburse.
     Thread-safe via DB-level auto-increment fallback.
     """
-    today = datetime.now().strftime("%Y%m%d")
+    today = _date_yyyymmdd(apply_date)
     like_pattern = f"{prefix}{today}%"
 
     # Find the max sequence number for today
@@ -28,14 +37,17 @@ def generate_apply_code(db: Session, prefix: str) -> str:
     return f"{prefix}{today}{seq:03d}"
 
 
-def generate_title(db: Session, applicant_name: str) -> str:
-    today = datetime.now().strftime("%Y%m%d")
-    like_pattern = f"%报销单{today}%"
+def generate_title(db: Session, applicant_name: str, apply_date: str | None = None) -> str:
+    date_str = _date_yyyymmdd(apply_date)
+    like_pattern = f"%报销单{date_str}%"
 
     result = db.execute(
-        text("SELECT COUNT(*) FROM bx_reimburse_apply WHERE title LIKE :p"),
-        {"p": like_pattern}
+        text(
+            "SELECT MAX(CAST(RIGHT(title, 3) AS UNSIGNED)) FROM bx_reimburse_apply "
+            "WHERE del_flag = 0 AND title LIKE :p"
+        ),
+        {"p": like_pattern},
     ).scalar()
 
     seq = (result or 0) + 1
-    return f"{applicant_name}报销单{today}{seq:03d}"
+    return f"{applicant_name}报销单{date_str}{seq:03d}"

+ 13 - 0
bx/bx-backend/app/utils/date_range.py

@@ -23,3 +23,16 @@ def parse_create_time_day_bounds(apply_date: str | None) -> tuple[datetime, date
         return day_start, day_start + timedelta(days=1)
     except ValueError:
         return None
+
+
+def resolve_apply_create_time(apply_date: str | None, now: datetime | None = None) -> datetime:
+    """申请日 YYYY-MM-DD → create_time;当天用当前时刻,其它日期用当日 0 点。"""
+    if now is None:
+        now = datetime.now()
+    bounds = parse_create_time_day_bounds(apply_date)
+    if not bounds:
+        return now
+    day_start, _ = bounds
+    if day_start.date() == now.date():
+        return now
+    return day_start

+ 1 - 1
bx/bx-mobile/src/api/client.ts

@@ -4,7 +4,7 @@ import { useAuthStore } from '@/stores/auth'
 
 const client = axios.create({
   baseURL: '/api',
-  timeout: 15000,
+  timeout: 0, // 0 = no timeout
   headers: { 'Content-Type': 'application/json' },
 })
 

+ 7 - 2
bx/bx-web/nginx.bx.shanxiachem.com.conf

@@ -5,6 +5,9 @@ server {
     root /iflytek/bx/dist;
     index index.html;
 
+    # 0 = disable body size check
+    client_max_body_size 0;
+
     # Frontend SPA routing
     location / {
         try_files $uri $uri/ /index.html;
@@ -12,6 +15,7 @@ server {
 
     # Reimbursement backend API (FastAPI on 8011)
     location /api/ {
+        client_max_body_size 0;
         proxy_pass http://121.43.228.102:8011;
         proxy_http_version 1.1;
         proxy_set_header Host $host;
@@ -19,8 +23,9 @@ server {
         proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
         proxy_set_header X-Forwarded-Proto $scheme;
         proxy_connect_timeout 60s;
-        proxy_send_timeout 60s;
-        proxy_read_timeout 60s;
+        # 0 = disable timeout
+        proxy_send_timeout 0;
+        proxy_read_timeout 0;
     }
 
     # Cache static assets generated by Vite

+ 1 - 1
bx/bx-web/src/api/client.ts

@@ -4,7 +4,7 @@ import { ElMessage } from 'element-plus'
 
 const api = axios.create({
   baseURL: '/api',
-  timeout: 15000,
+  timeout: 0, // 0 = no timeout
   headers: { 'Content-Type': 'application/json' },
 })
 

+ 2 - 2
bx/bx-web/src/api/index.ts

@@ -200,8 +200,8 @@ export const advanceApi = {
 }
 
 export const reimburseApi = {
-  generateTitle() {
-    return api.get('/reimburses/generate-title')
+  generateTitle(applyDate?: string) {
+    return api.get('/reimburses/generate-title', { params: applyDate ? { applyDate } : undefined })
   },
   list(params: {
     page: number

+ 297 - 0
bx/bx-web/src/components/base/AttachmentUploadPanel.vue

@@ -0,0 +1,297 @@
+<script setup lang="ts">
+import { ref } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { Plus, Delete, Download, Document, Loading } from '@element-plus/icons-vue'
+import { attachmentApi } from '../../api'
+import { isImageFile, fileExt, type AttachmentFile } from '../../utils/attachment'
+
+const props = defineProps<{
+  modelValue: AttachmentFile[]
+  upload: (file: File) => Promise<AttachmentFile>
+}>()
+
+const emit = defineEmits<{
+  'update:modelValue': [AttachmentFile[]]
+}>()
+
+interface PendingItem {
+  uid: string
+  fileName: string
+  fileType: string
+}
+
+const pending = ref<PendingItem[]>([])
+const previewVisible = ref(false)
+const previewUrl = ref('')
+const previewFileName = ref('')
+
+async function handleUpload(options: { file: File }) {
+  const file = options.file
+  const uid = `${Date.now()}-${Math.random().toString(36).slice(2)}`
+  pending.value.push({
+    uid,
+    fileName: file.name,
+    fileType: file.type || '',
+  })
+  try {
+    const fileData = await props.upload(file)
+    emit('update:modelValue', [...props.modelValue, fileData])
+  } catch (e: any) {
+    ElMessage.error(e?.message || '上传失败')
+  } finally {
+    pending.value = pending.value.filter((p) => p.uid !== uid)
+  }
+}
+
+async function handleRemove(index: number) {
+  const file = props.modelValue[index]
+  try {
+    await ElMessageBox.confirm('确定删除该附件?', '提示', { type: 'warning' })
+    await attachmentApi.delete(file.id)
+    const next = [...props.modelValue]
+    next.splice(index, 1)
+    emit('update:modelValue', next)
+    ElMessage.success('已删除')
+  } catch {
+    /* cancelled or already errored */
+  }
+}
+
+async function handleDownload(file: { id: number; fileName: string }) {
+  try {
+    const res: any = await attachmentApi.download(file.id)
+    const blob = new Blob([res], { type: 'application/octet-stream' })
+    const url = URL.createObjectURL(blob)
+    const a = document.createElement('a')
+    a.href = url
+    a.download = file.fileName
+    a.click()
+    URL.revokeObjectURL(url)
+  } catch (e: any) {
+    ElMessage.error(e?.message || '下载失败')
+  }
+}
+
+async function handlePreview(file: { id: number; fileName: string; fileType: string; previewUrl?: string }) {
+  try {
+    if (file.previewUrl) {
+      previewUrl.value = file.previewUrl
+    } else {
+      const res: any = await attachmentApi.preview(file.id)
+      previewUrl.value = res.data.url
+    }
+    previewFileName.value = file.fileName
+    previewVisible.value = true
+  } catch (e: any) {
+    ElMessage.error(e?.message || '预览失败')
+  }
+}
+</script>
+
+<template>
+  <div class="attachment-upload-area">
+    <el-upload :http-request="handleUpload" :show-file-list="false" multiple>
+      <div class="upload-trigger">
+        <el-icon class="upload-icon"><Plus /></el-icon>
+        <p>点击上传附件</p>
+      </div>
+    </el-upload>
+
+    <div v-if="pending.length || modelValue.length" class="attachment-grid">
+      <div
+        v-for="item in pending"
+        :key="item.uid"
+        class="attachment-card attachment-card--pending"
+        aria-busy="true"
+        aria-label="附件上传中"
+      >
+        <div class="card-preview card-preview--skeleton">
+          <el-icon v-if="isImageFile(item.fileType)" class="pending-icon is-loading" :size="28">
+            <Loading />
+          </el-icon>
+          <el-icon v-else class="pending-icon is-loading" :size="28"><Document /></el-icon>
+        </div>
+        <div class="card-footer card-footer--pending">
+          <span class="pending-name" :title="item.fileName">{{ item.fileName }}</span>
+          <span class="pending-hint">上传中…</span>
+        </div>
+      </div>
+
+      <div v-for="(file, idx) in modelValue" :key="file.id" class="attachment-card">
+        <div class="card-preview" @click="isImageFile(file.fileType) && handlePreview(file)">
+          <img v-if="isImageFile(file.fileType) && file.previewUrl" :src="file.previewUrl" :alt="file.fileName" />
+          <div v-else class="card-file-icon">
+            <el-icon :size="32"><Document /></el-icon>
+            <span class="card-ext">{{ fileExt(file.fileName) }}</span>
+          </div>
+        </div>
+        <div class="card-footer">
+          <el-button size="small" text @click="handleRemove(idx)">
+            <el-icon><Delete /></el-icon> 删除
+          </el-button>
+          <el-button size="small" text @click="handleDownload(file)">
+            <el-icon><Download /></el-icon> 下载
+          </el-button>
+        </div>
+      </div>
+    </div>
+
+    <el-dialog v-model="previewVisible" title="图片预览" width="700px" class="bx-dialog" align-center append-to-body>
+      <div class="img-preview-wrap">
+        <img :src="previewUrl" :alt="previewFileName" class="img-preview" />
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<style scoped>
+.attachment-upload-area {
+  margin-top: 4px;
+}
+
+.upload-trigger {
+  width: 100%;
+  border: 1px dashed var(--border);
+  border-radius: var(--radius-md);
+  padding: 20px;
+  text-align: center;
+  cursor: pointer;
+  transition: border-color 0.2s;
+  color: var(--text-muted);
+}
+
+.upload-trigger:hover {
+  border-color: var(--primary);
+  color: var(--primary-dark);
+}
+
+.upload-trigger .upload-icon {
+  font-size: 24px;
+  margin-bottom: 6px;
+}
+
+.upload-trigger p {
+  margin: 0;
+  font-size: 13px;
+}
+
+.attachment-grid {
+  margin-top: 10px;
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
+  gap: 12px;
+}
+
+.attachment-card {
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  overflow: hidden;
+  background: var(--surface);
+  transition: box-shadow 0.2s;
+}
+
+.attachment-card:hover {
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+}
+
+.attachment-card--pending {
+  pointer-events: none;
+}
+
+.attachment-card--pending:hover {
+  box-shadow: none;
+}
+
+.card-preview {
+  width: 100%;
+  aspect-ratio: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  overflow: hidden;
+  background: #f8fafc;
+  cursor: default;
+}
+
+.card-preview img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+
+.card-preview:has(img) {
+  cursor: pointer;
+}
+
+.card-preview--skeleton {
+  position: relative;
+  background: linear-gradient(110deg, #f1f5f9 8%, #e2e8f0 18%, #f1f5f9 33%);
+  background-size: 200% 100%;
+  animation: attachment-shimmer 1.4s ease-in-out infinite;
+}
+
+.pending-icon {
+  color: var(--primary);
+  z-index: 1;
+}
+
+.card-file-icon {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 4px;
+  color: var(--text-muted);
+}
+
+.card-ext {
+  font-size: 11px;
+  font-weight: 600;
+  letter-spacing: 0.04em;
+}
+
+.card-footer {
+  display: flex;
+  justify-content: space-around;
+  padding: 4px 0;
+  border-top: 1px solid var(--border);
+}
+
+.card-footer--pending {
+  flex-direction: column;
+  align-items: stretch;
+  gap: 2px;
+  padding: 8px;
+}
+
+.pending-name {
+  font-size: 11px;
+  color: var(--text);
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.pending-hint {
+  font-size: 11px;
+  color: var(--primary);
+}
+
+.img-preview-wrap {
+  text-align: center;
+}
+
+.img-preview {
+  max-width: 100%;
+  max-height: 70vh;
+  object-fit: contain;
+}
+
+@keyframes attachment-shimmer {
+  0% {
+    background-position: 200% 0;
+  }
+  100% {
+    background-position: -200% 0;
+  }
+}
+</style>

+ 17 - 0
bx/bx-web/src/utils/attachment.ts

@@ -0,0 +1,17 @@
+export interface AttachmentFile {
+  id: number
+  fileName: string
+  fileType: string
+  fileSize: number
+  previewUrl: string
+  _existing?: boolean
+}
+
+export function isImageFile(fileType: string): boolean {
+  return !!fileType && fileType.startsWith('image/')
+}
+
+export function fileExt(name: string): string {
+  const i = name.lastIndexOf('.')
+  return i >= 0 ? name.slice(i + 1).toUpperCase() : 'FILE'
+}

+ 19 - 103
bx/bx-web/src/views/pages/AdvanceIssuePage.vue

@@ -17,6 +17,8 @@ import BaseOpButton from '../../components/base/BaseOpButton.vue'
 import ApprovalFlowView from '../../components/base/ApprovalFlowView.vue'
 import BaseClampText from '../../components/base/BaseClampText.vue'
 import BaseDetailTableSection from '../../components/base/BaseDetailTableSection.vue'
+import AttachmentUploadPanel from '../../components/base/AttachmentUploadPanel.vue'
+import { isImageFile, fileExt, type AttachmentFile } from '../../utils/attachment'
 
 type DialogKey = 'issue' | 'view'
 
@@ -77,8 +79,7 @@ const issueRemark = ref('')
 const viewData = ref<Record<string, any>>({})
 
 const actualAmount = ref<number | string>('')
-const uploadedFiles = ref<Array<{ id: number; fileName: string; fileType: string; fileSize: number; previewUrl: string }>>([])
-const uploadLoading = ref(false)
+const uploadedFiles = ref<AttachmentFile[]>([])
 const previewVisible = ref(false)
 const previewUrl = ref('')
 const previewFileName = ref('')
@@ -186,40 +187,22 @@ function openIssueDialog(row: any) {
   dialogs.issue = true
 }
 
-async function handleUpload(options: any) {
-  uploadLoading.value = true
-  try {
-    const file = options.file as File
-    const res: any = await attachmentApi.upload(file, 'advance_issue', currentRow.value.id)
-    const fileData: { id: number; fileName: string; fileType: string; fileSize: number; previewUrl: string } = {
-      id: res.data.id,
-      fileName: res.data.fileName,
-      fileType: res.data.fileType || '',
-      fileSize: res.data.fileSize,
-      previewUrl: '',
-    }
-    if (isImageFile(fileData.fileType)) {
-      try {
-        const previewRes: any = await attachmentApi.preview(fileData.id)
-        fileData.previewUrl = previewRes.data.url
-      } catch { /* preview not critical */ }
-    }
-    uploadedFiles.value.push(fileData)
-  } catch (e: any) {
-    ElMessage.error(e.message || '上传失败')
-  } finally {
-    uploadLoading.value = false
+async function uploadAdvanceIssueAttachment(file: File): Promise<AttachmentFile> {
+  const res: any = await attachmentApi.upload(file, 'advance_issue', currentRow.value.id)
+  const fileData: AttachmentFile = {
+    id: res.data.id,
+    fileName: res.data.fileName,
+    fileType: res.data.fileType || '',
+    fileSize: res.data.fileSize,
+    previewUrl: '',
   }
-}
-
-async function handleRemoveAttachment(index: number) {
-  const file = uploadedFiles.value[index]
-  try {
-    await ElMessageBox.confirm('确定删除该附件?', '提示', { type: 'warning' })
-    await attachmentApi.delete(file.id)
-    uploadedFiles.value.splice(index, 1)
-    ElMessage.success('已删除')
-  } catch { /* cancelled or already errored */ }
+  if (isImageFile(fileData.fileType)) {
+    try {
+      const previewRes: any = await attachmentApi.preview(fileData.id)
+      fileData.previewUrl = previewRes.data.url
+    } catch { /* preview not critical */ }
+  }
+  return fileData
 }
 
 async function handleDownload(file: { id: number; fileName: string }) {
@@ -259,15 +242,6 @@ function formatFileSize(bytes: number): string {
   return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
 }
 
-function isImageFile(fileType: string): boolean {
-  return fileType?.startsWith('image/')
-}
-
-function fileExt(name: string): string {
-  const ext = name.split('.').pop()
-  return ext ? ext.toUpperCase() : 'FILE'
-}
-
 async function submitIssue() {
   if (uploadedFiles.value.length === 0) {
     ElMessage.warning('请至少上传 1 个发放附件')
@@ -487,35 +461,7 @@ async function openView(row: any) {
 
       <div class="form-section form-section-last">
         <h4>发放附件(必填)</h4>
-        <div class="attachment-upload-area">
-          <el-upload
-            :http-request="handleUpload"
-            :show-file-list="false"
-            multiple
-            :disabled="uploadLoading"
-          >
-            <div class="upload-trigger">
-              <el-icon class="upload-icon"><Plus /></el-icon>
-              <p>点击上传附件</p>
-            </div>
-          </el-upload>
-
-          <div v-if="uploadedFiles.length" class="attachment-grid">
-            <div v-for="(file, idx) in uploadedFiles" :key="file.id" class="attachment-card">
-              <div class="card-preview" @click="isImageFile(file.fileType) && handlePreview(file)">
-                <img v-if="isImageFile(file.fileType) && file.previewUrl" :src="file.previewUrl" :alt="file.fileName" />
-                <div v-else class="card-file-icon">
-                  <el-icon :size="32"><Document /></el-icon>
-                  <span class="card-ext">{{ fileExt(file.fileName) }}</span>
-                </div>
-              </div>
-              <div class="card-footer">
-                <el-button size="small" text @click="handleRemoveAttachment(idx)"><el-icon><Delete /></el-icon> 删除</el-button>
-                <el-button size="small" text @click="handleDownload(file)"><el-icon><Download /></el-icon> 下载</el-button>
-              </div>
-            </div>
-          </div>
-        </div>
+        <AttachmentUploadPanel v-model="uploadedFiles" :upload="uploadAdvanceIssueAttachment" />
       </div>
 
       <template #footer>
@@ -623,36 +569,6 @@ async function openView(row: any) {
   overflow-y: auto;
 }
 
-.attachment-upload-area {
-  margin-top: 4px;
-}
-
-.upload-trigger {
-  width: 100%;
-  border: 1px dashed var(--border);
-  border-radius: var(--radius-md);
-  padding: 20px;
-  text-align: center;
-  cursor: pointer;
-  transition: border-color 0.2s;
-  color: var(--text-muted);
-}
-
-.upload-trigger:hover {
-  border-color: var(--primary);
-  color: var(--primary-dark);
-}
-
-.upload-trigger .upload-icon {
-  font-size: 24px;
-  margin-bottom: 6px;
-}
-
-.upload-trigger p {
-  margin: 0;
-  font-size: 13px;
-}
-
 .attachment-grid {
   margin-top: 10px;
   display: grid;

+ 21 - 126
bx/bx-web/src/views/pages/PaymentPage.vue

@@ -16,6 +16,8 @@ import BaseOpButton from '../../components/base/BaseOpButton.vue'
 import ApprovalFlowView from '../../components/base/ApprovalFlowView.vue'
 import BaseClampText from '../../components/base/BaseClampText.vue'
 import BaseDetailTableSection from '../../components/base/BaseDetailTableSection.vue'
+import AttachmentUploadPanel from '../../components/base/AttachmentUploadPanel.vue'
+import { isImageFile, fileExt, type AttachmentFile } from '../../utils/attachment'
 
 type DialogKey = 'viewDetail' | 'issue' | 'offset' | 'processedDetail'
 
@@ -77,8 +79,7 @@ const viewData = ref<Record<string, any>>({})
 const issueRemark = ref('')
 const actualAmount = ref('')
 const offsetRemark = ref('')
-const uploadedFiles = ref<Array<{ id: number; fileName: string; fileType: string; fileSize: number; previewUrl: string }>>([])
-const uploadLoading = ref(false)
+const uploadedFiles = ref<AttachmentFile[]>([])
 const previewVisible = ref(false)
 const previewUrl = ref('')
 const previewFileName = ref('')
@@ -146,41 +147,23 @@ function handleReset() {
   search()
 }
 
-async function handleUpload(options: any) {
-  uploadLoading.value = true
-  try {
-    const file = options.file as File
-    const bizType = dialogs.issue ? 'reimburse_pay' : 'reimburse_offset'
-    const res: any = await attachmentApi.upload(file, bizType, currentRow.value.id)
-    const fileData: { id: number; fileName: string; fileType: string; fileSize: number; previewUrl: string } = {
-      id: res.data.id,
-      fileName: res.data.fileName,
-      fileType: res.data.fileType || '',
-      fileSize: res.data.fileSize,
-      previewUrl: '',
-    }
-    if (isImageFile(fileData.fileType)) {
-      try {
-        const previewRes: any = await attachmentApi.preview(fileData.id)
-        fileData.previewUrl = previewRes.data.url
-      } catch { /* preview not critical */ }
-    }
-    uploadedFiles.value.push(fileData)
-  } catch (e: any) {
-    ElMessage.error(e.message || '上传失败')
-  } finally {
-    uploadLoading.value = false
+async function uploadPaymentAttachment(file: File): Promise<AttachmentFile> {
+  const bizType = dialogs.issue ? 'reimburse_pay' : 'reimburse_offset'
+  const res: any = await attachmentApi.upload(file, bizType, currentRow.value.id)
+  const fileData: AttachmentFile = {
+    id: res.data.id,
+    fileName: res.data.fileName,
+    fileType: res.data.fileType || '',
+    fileSize: res.data.fileSize,
+    previewUrl: '',
   }
-}
-
-async function handleRemoveAttachment(index: number) {
-  const file = uploadedFiles.value[index]
-  try {
-    await ElMessageBox.confirm('确定删除该附件?', '提示', { type: 'warning' })
-    await attachmentApi.delete(file.id)
-    uploadedFiles.value.splice(index, 1)
-    ElMessage.success('已删除')
-  } catch { /* cancelled or already errored */ }
+  if (isImageFile(fileData.fileType)) {
+    try {
+      const previewRes: any = await attachmentApi.preview(fileData.id)
+      fileData.previewUrl = previewRes.data.url
+    } catch { /* preview not critical */ }
+  }
+  return fileData
 }
 
 async function handleDownload(file: { id: number; fileName: string }) {
@@ -220,15 +203,6 @@ function formatFileSize(bytes: number): string {
   return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
 }
 
-function isImageFile(fileType: string): boolean {
-  return fileType?.startsWith('image/')
-}
-
-function fileExt(name: string): string {
-  const ext = name.split('.').pop()
-  return ext ? ext.toUpperCase() : 'FILE'
-}
-
 async function openView(row: any) {
   viewData.value = row
   viewAttachments.value = []
@@ -811,34 +785,7 @@ function processedSummaryMethod(params: { data: any[] }) {
       </div>
       <div class="form-section form-section-last">
         <h4>发放附件</h4>
-        <div class="attachment-upload-area">
-          <el-upload
-            :http-request="handleUpload"
-            :show-file-list="false"
-            multiple
-            :disabled="uploadLoading"
-          >
-            <div class="upload-trigger">
-              <el-icon class="upload-icon"><Plus /></el-icon>
-              <p>点击上传附件</p>
-            </div>
-          </el-upload>
-          <div v-if="uploadedFiles.length" class="attachment-grid">
-            <div v-for="(file, idx) in uploadedFiles" :key="file.id" class="attachment-card">
-              <div class="card-preview" @click="isImageFile(file.fileType) && handlePreview(file)">
-                <img v-if="isImageFile(file.fileType) && file.previewUrl" :src="file.previewUrl" :alt="file.fileName" />
-                <div v-else class="card-file-icon">
-                  <el-icon :size="32"><Document /></el-icon>
-                  <span class="card-ext">{{ fileExt(file.fileName) }}</span>
-                </div>
-              </div>
-              <div class="card-footer">
-                <el-button size="small" text @click="handleRemoveAttachment(idx)"><el-icon><Delete /></el-icon> 删除</el-button>
-                <el-button size="small" text @click="handleDownload(file)"><el-icon><Download /></el-icon> 下载</el-button>
-              </div>
-            </div>
-          </div>
-        </div>
+        <AttachmentUploadPanel v-model="uploadedFiles" :upload="uploadPaymentAttachment" />
       </div>
       <template #footer>
         <BaseButton :icon="Close" @click="dialogs.issue = false">取消</BaseButton>
@@ -930,29 +877,7 @@ function processedSummaryMethod(params: { data: any[] }) {
       <!-- 附件 -->
       <div class="form-section form-section-last">
         <h4>冲抵附件(必填)</h4>
-        <div class="attachment-upload-area">
-          <el-upload :http-request="handleUpload" :show-file-list="false" multiple :disabled="uploadLoading">
-            <div class="upload-trigger">
-              <el-icon class="upload-icon"><Plus /></el-icon>
-              <p>点击上传附件</p>
-            </div>
-          </el-upload>
-          <div v-if="uploadedFiles.length" class="attachment-grid">
-            <div v-for="(file, idx) in uploadedFiles" :key="file.id" class="attachment-card">
-              <div class="card-preview" @click="isImageFile(file.fileType) && handlePreview(file)">
-                <img v-if="isImageFile(file.fileType) && file.previewUrl" :src="file.previewUrl" :alt="file.fileName" />
-                <div v-else class="card-file-icon">
-                  <el-icon :size="32"><Document /></el-icon>
-                  <span class="card-ext">{{ fileExt(file.fileName) }}</span>
-                </div>
-              </div>
-              <div class="card-footer">
-                <el-button size="small" text @click="handleRemoveAttachment(idx)"><el-icon><Delete /></el-icon> 删除</el-button>
-                <el-button size="small" text @click="handleDownload(file)"><el-icon><Download /></el-icon> 下载</el-button>
-              </div>
-            </div>
-          </div>
-        </div>
+        <AttachmentUploadPanel v-model="uploadedFiles" :upload="uploadPaymentAttachment" />
       </div>
 
       <template #footer>
@@ -1121,36 +1046,6 @@ function processedSummaryMethod(params: { data: any[] }) {
 </template>
 
 <style scoped>
-.attachment-upload-area {
-  margin-top: 4px;
-}
-
-.upload-trigger {
-  width: 100%;
-  border: 1px dashed var(--border);
-  border-radius: var(--radius-md);
-  padding: 20px;
-  text-align: center;
-  cursor: pointer;
-  transition: border-color 0.2s;
-  color: var(--text-muted);
-}
-
-.upload-trigger:hover {
-  border-color: var(--primary);
-  color: var(--primary-dark);
-}
-
-.upload-trigger .upload-icon {
-  font-size: 24px;
-  margin-bottom: 6px;
-}
-
-.upload-trigger p {
-  margin: 0;
-  font-size: 13px;
-}
-
 .section-attachment-title {
   margin-top: 12px;
   margin-bottom: 2px;

+ 69 - 109
bx/bx-web/src/views/pages/ReimburseApplyPage.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { reactive, ref, computed, onMounted } from 'vue'
+import { reactive, ref, computed, onMounted, watch } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import { Plus, EditPen, Upload, View, Close, Delete, Download, ZoomIn, Document } from '@element-plus/icons-vue'
 import { reimburseApi, attachmentApi } from '../../api'
@@ -16,6 +16,8 @@ import BaseTag from '../../components/base/BaseTag.vue'
 import BaseOpButton from '../../components/base/BaseOpButton.vue'
 import ApprovalFlowView from '../../components/base/ApprovalFlowView.vue'
 import BaseClampText from '../../components/base/BaseClampText.vue'
+import AttachmentUploadPanel from '../../components/base/AttachmentUploadPanel.vue'
+import { isImageFile, fileExt, type AttachmentFile } from '../../utils/attachment'
 
 type DialogKey = 'add' | 'view' | 'viewFlow'
 
@@ -90,13 +92,23 @@ const addForm = reactive({
   ] as DetailLine[]
 })
 
+function todayApplyDate(): string {
+  const d = new Date()
+  const y = d.getFullYear()
+  const m = String(d.getMonth() + 1).padStart(2, '0')
+  const day = String(d.getDate()).padStart(2, '0')
+  return `${y}-${m}-${day}`
+}
+
+const addFormApplyDate = ref(todayApplyDate())
+const titleLoading = ref(false)
+
 const viewData = ref<Record<string, any>>({})
 const selectedRows = ref<any[]>([])
 const flowNodes = ref<any[]>([])
 const flowLoading = ref(false)
 const viewFlowNodes = ref<any[]>([])
-const uploadedFiles = ref<Array<{ id: number; fileName: string; fileType: string; fileSize: number; previewUrl: string }>>([])
-const uploadLoading = ref(false)
+const uploadedFiles = ref<AttachmentFile[]>([])
 const previewVisible = ref(false)
 const previewUrl = ref('')
 const previewFileName = ref('')
@@ -108,17 +120,27 @@ const detailTotalAmount = computed(() => {
   return addForm.details.reduce((sum: number, d) => sum + Number(d.amount || 0), 0)
 })
 
+async function refreshGeneratedTitle() {
+  if (editingId.value) return
+  titleLoading.value = true
+  try {
+    const res: any = await reimburseApi.generateTitle(addFormApplyDate.value)
+    addForm.title = res.data || ''
+  } catch { /* fallback: user can type manually */ }
+  finally {
+    titleLoading.value = false
+  }
+}
+
 async function openAddDialog() {
   addForm.title = ''
   addForm.remark = ''
   addForm.details = [{ expenseItemId: '', amount: '', remark: '' }]
   uploadedFiles.value = []
   editingId.value = null
+  addFormApplyDate.value = todayApplyDate()
   dialogs.add = true
-  try {
-    const res: any = await reimburseApi.generateTitle()
-    addForm.title = res.data || ''
-  } catch { /* fallback: user can type manually */ }
+  await refreshGeneratedTitle()
 }
 
 async function openEditDialog(row: any) {
@@ -176,6 +198,12 @@ onMounted(() => {
   refreshListAndStats()
 })
 
+watch(addFormApplyDate, () => {
+  if (dialogs.add && !editingId.value) {
+    refreshGeneratedTitle()
+  }
+})
+
 function toggleMoreFilters() {
   showMoreFilters.value = !showMoreFilters.value
 }
@@ -242,6 +270,7 @@ async function submitAdd() {
     } else {
       const res: any = await reimburseApi.create({
         title: addForm.title || undefined,
+        applyDate: addFormApplyDate.value,
         remark: addForm.remark,
         details: validDetails.map(d => ({
           expenseItemId: Number(d.expenseItemId),
@@ -380,40 +409,22 @@ async function handleDelete(row: any) {
   }
 }
 
-async function handleUpload(options: any) {
-  uploadLoading.value = true
-  try {
-    const file = options.file as File
-    const res: any = await attachmentApi.upload(file, 'reimburse_apply', 0)
-    const fileData: { id: number; fileName: string; fileType: string; fileSize: number; previewUrl: string } = {
-      id: res.data.id,
-      fileName: res.data.fileName,
-      fileType: res.data.fileType || '',
-      fileSize: res.data.fileSize,
-      previewUrl: '',
-    }
-    if (isImageFile(fileData.fileType)) {
-      try {
-        const previewRes: any = await attachmentApi.preview(fileData.id)
-        fileData.previewUrl = previewRes.data.url
-      } catch { /* preview not critical */ }
-    }
-    uploadedFiles.value.push(fileData)
-  } catch (e: any) {
-    ElMessage.error(e.message || '上传失败')
-  } finally {
-    uploadLoading.value = false
+async function uploadReimburseAttachment(file: File): Promise<AttachmentFile> {
+  const res: any = await attachmentApi.upload(file, 'reimburse_apply', 0)
+  const fileData: AttachmentFile = {
+    id: res.data.id,
+    fileName: res.data.fileName,
+    fileType: res.data.fileType || '',
+    fileSize: res.data.fileSize,
+    previewUrl: '',
   }
-}
-
-async function handleRemoveAttachment(index: number) {
-  const file = uploadedFiles.value[index]
-  try {
-    await ElMessageBox.confirm('确定删除该附件?', '提示', { type: 'warning' })
-    await attachmentApi.delete(file.id)
-    uploadedFiles.value.splice(index, 1)
-    ElMessage.success('已删除')
-  } catch { /* cancelled or already errored */ }
+  if (isImageFile(fileData.fileType)) {
+    try {
+      const previewRes: any = await attachmentApi.preview(fileData.id)
+      fileData.previewUrl = previewRes.data.url
+    } catch { /* preview not critical */ }
+  }
+  return fileData
 }
 
 async function handleDownload(file: { id: number; fileName: string }) {
@@ -453,15 +464,6 @@ function formatFileSize(bytes: number): string {
   return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
 }
 
-function isImageFile(fileType: string): boolean {
-  return fileType?.startsWith('image/')
-}
-
-function fileExt(name: string): string {
-  const ext = name.split('.').pop()
-  return ext ? ext.toUpperCase() : 'FILE'
-}
-
 function summaryMethod(params: { data: any[] }) {
   const data = params.data
   const n = data.length
@@ -607,12 +609,28 @@ function viewDetailSummary({ columns, data }: { columns: any[]; data: any[] }) {
 
       <div class="form-section">
         <h4>基础信息</h4>
-        <div class="form-grid-2">
+        <div v-if="!editingId" class="form-grid-2">
           <div class="field">
+            <label>申请时间</label>
+            <el-date-picker
+              v-model="addFormApplyDate"
+              type="date"
+              value-format="YYYY-MM-DD"
+              placeholder="选择申请日期"
+              class="bx-date-picker field-date-picker"
+            />
+          </div>
+          <div class="field" v-loading="titleLoading">
             <label>报销标题</label>
             <BaseInput v-model="addForm.title" placeholder="不填则自动生成" />
           </div>
         </div>
+        <div v-else class="form-grid-2">
+          <div class="field">
+            <label>报销标题</label>
+            <BaseInput v-model="addForm.title" disabled />
+          </div>
+        </div>
       </div>
 
       <div class="form-section">
@@ -679,35 +697,7 @@ function viewDetailSummary({ columns, data }: { columns: any[]; data: any[] }) {
 
       <div class="form-section">
         <h4>附件上传</h4>
-        <div class="attachment-upload-area">
-          <el-upload
-            :http-request="handleUpload"
-            :show-file-list="false"
-            multiple
-            :disabled="uploadLoading"
-          >
-            <div class="upload-trigger">
-              <el-icon class="upload-icon"><Plus /></el-icon>
-              <p>点击上传附件</p>
-            </div>
-          </el-upload>
-
-          <div v-if="uploadedFiles.length" class="attachment-grid">
-            <div v-for="(file, idx) in uploadedFiles" :key="file.id" class="attachment-card">
-              <div class="card-preview" @click="isImageFile(file.fileType) && handlePreview(file)">
-                <img v-if="isImageFile(file.fileType) && file.previewUrl" :src="file.previewUrl" :alt="file.fileName" />
-                <div v-else class="card-file-icon">
-                  <el-icon :size="32"><Document /></el-icon>
-                  <span class="card-ext">{{ fileExt(file.fileName) }}</span>
-                </div>
-              </div>
-              <div class="card-footer">
-                <el-button size="small" text @click="handleRemoveAttachment(idx)"><el-icon><Delete /></el-icon> 删除</el-button>
-                <el-button size="small" text @click="handleDownload(file)"><el-icon><Download /></el-icon> 下载</el-button>
-              </div>
-            </div>
-          </div>
-        </div>
+        <AttachmentUploadPanel v-model="uploadedFiles" :upload="uploadReimburseAttachment" />
       </div>
       <div class="form-section form-section-last">
         <h4>备注说明</h4>
@@ -959,36 +949,6 @@ function viewDetailSummary({ columns, data }: { columns: any[]; data: any[] }) {
   font-weight: 600;
 }
 
-.attachment-upload-area {
-  margin-top: 4px;
-}
-
-.upload-trigger {
-  width: 100%;
-  border: 1px dashed var(--border);
-  border-radius: var(--radius-md);
-  padding: 20px;
-  text-align: center;
-  cursor: pointer;
-  transition: border-color 0.2s;
-  color: var(--text-muted);
-}
-
-.upload-trigger:hover {
-  border-color: var(--primary);
-  color: var(--primary-dark);
-}
-
-.upload-trigger .upload-icon {
-  font-size: 24px;
-  margin-bottom: 6px;
-}
-
-.upload-trigger p {
-  margin: 0;
-  font-size: 13px;
-}
-
 .attachment-grid {
   margin-top: 10px;
   display: grid;