AdvanceIssuePage.vue 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677
  1. <script setup lang="ts">
  2. import { reactive, ref, computed, onMounted } from 'vue'
  3. import { ElMessage } from 'element-plus'
  4. import { Check, Download, View, Upload, Close, Plus, Delete, ZoomIn, Document } from '@element-plus/icons-vue'
  5. import { paymentApi, attachmentApi, advanceApi } from '../../api'
  6. import { ElMessageBox } from 'element-plus'
  7. import { statusLabel, statusTone } from '../../utils/format'
  8. import { useServerPaged } from '../../hooks/useServerPaged'
  9. import ListPageScaffold from '../../components/template/ListPageScaffold.vue'
  10. import BaseButton from '../../components/base/BaseButton.vue'
  11. import BaseDialog from '../../components/base/BaseDialog.vue'
  12. import BaseInput from '../../components/base/BaseInput.vue'
  13. import BaseSelect from '../../components/base/BaseSelect.vue'
  14. import BaseTag from '../../components/base/BaseTag.vue'
  15. import BaseOpButton from '../../components/base/BaseOpButton.vue'
  16. import ApprovalFlowView from '../../components/base/ApprovalFlowView.vue'
  17. import BaseClampText from '../../components/base/BaseClampText.vue'
  18. import BaseDetailTableSection from '../../components/base/BaseDetailTableSection.vue'
  19. type DialogKey = 'issue' | 'view'
  20. const showMoreFilters = ref(false)
  21. const loadingMap = reactive<Record<string, boolean>>({})
  22. const dialogs = reactive<Record<DialogKey, boolean>>({
  23. issue: false,
  24. view: false
  25. })
  26. const activeTab = ref('pending')
  27. const filterForm = reactive({
  28. keyword: '',
  29. applicant: '',
  30. })
  31. const { rows, total, currentPage, pageSize, rangeText, loading, refresh, search, onSizeChange } = useServerPaged(
  32. async (params) => {
  33. const issueStatus = activeTab.value === 'pending' ? 'pending' : 'issued'
  34. const res: any = await paymentApi.advanceIssues({ ...params, issueStatus })
  35. const data = res.data
  36. if (Array.isArray(data)) {
  37. return { rows: data, total: data.length }
  38. }
  39. return { rows: data?.rows ?? [], total: data?.total ?? 0 }
  40. }
  41. )
  42. const stats = computed(() => {
  43. const list = rows.value
  44. const unissued = list.filter((r: any) => r.issueStatus !== 'issued').length
  45. const issued = list.filter((r: any) => r.issueStatus === 'issued').length
  46. const issuedAmount = list.filter((r: any) => r.issueStatus === 'issued').reduce((s: number, r: any) => s + Number(r.amount || 0), 0)
  47. const unissuedAmount = list.filter((r: any) => r.issueStatus !== 'issued').reduce((s: number, r: any) => s + Number(r.amount || 0), 0)
  48. return [
  49. { name: '待发放', value: String(unissued) },
  50. { name: '已发放', value: String(issued) },
  51. { name: '本月发放金额(元)', value: issuedAmount.toLocaleString('zh-CN') },
  52. { name: '待发放金额(元)', value: unissuedAmount.toLocaleString('zh-CN') }
  53. ]
  54. })
  55. const currentRow = ref<Record<string, any>>({})
  56. const selectedRows = ref<any[]>([])
  57. const issueRemark = ref('')
  58. const viewData = ref<Record<string, any>>({})
  59. const actualAmount = ref<number | string>('')
  60. const uploadedFiles = ref<Array<{ id: number; fileName: string; fileType: string; fileSize: number; previewUrl: string }>>([])
  61. const uploadLoading = ref(false)
  62. const previewVisible = ref(false)
  63. const previewUrl = ref('')
  64. const previewFileName = ref('')
  65. const viewAttachments = ref<Array<any>>([])
  66. const approvalNodes = ref<Array<any>>([])
  67. const approvalLoading = ref(false)
  68. const offsetRecords = ref<Array<any>>([])
  69. function issueStatusLabel(status: string) {
  70. return status === 'issued' ? '已发放' : '待发放'
  71. }
  72. function issueStatusTone(status: string) {
  73. return status === 'issued' ? 'done' : 'warning'
  74. }
  75. onMounted(() => {
  76. refresh()
  77. })
  78. function onSelectionChange(rows: any[]) {
  79. selectedRows.value = rows
  80. }
  81. function handleToolbarIssue() {
  82. if (selectedRows.value.length === 0) {
  83. ElMessage.warning('请先勾选一条待发放记录')
  84. return
  85. }
  86. openIssueDialog(selectedRows.value[0])
  87. }
  88. function toggleMoreFilters() {
  89. showMoreFilters.value = !showMoreFilters.value
  90. }
  91. function handleTabChange(tab: string | number) {
  92. activeTab.value = String(tab)
  93. Object.assign(filterForm, { keyword: '', applicant: '' })
  94. search()
  95. }
  96. function handleSearch() {
  97. search({ ...filterForm })
  98. }
  99. function handleReset() {
  100. Object.assign(filterForm, { keyword: '', applicant: '' })
  101. search()
  102. }
  103. async function handleExport() {
  104. loadingMap.export = true
  105. try {
  106. const issueStatus = activeTab.value === 'pending' ? 'pending' : 'issued'
  107. const res: any = await paymentApi.exportAdvanceIssues({
  108. issueStatus,
  109. keyword: filterForm.keyword,
  110. })
  111. const blob = new Blob([res], { type: 'text/csv;charset=utf-8' })
  112. const url = URL.createObjectURL(blob)
  113. const a = document.createElement('a')
  114. a.href = url
  115. const statusLabel = activeTab.value === 'pending' ? '待发放' : '已发放'
  116. a.download = `备用金${statusLabel}列表.csv`
  117. a.click()
  118. URL.revokeObjectURL(url)
  119. ElMessage.success('导出成功')
  120. } catch (e: any) {
  121. ElMessage.error(e?.message || '导出失败')
  122. } finally {
  123. loadingMap.export = false
  124. }
  125. }
  126. function openIssueDialog(row: any) {
  127. currentRow.value = row
  128. issueRemark.value = ''
  129. actualAmount.value = row.amount || ''
  130. uploadedFiles.value = []
  131. dialogs.issue = true
  132. }
  133. async function handleUpload(options: any) {
  134. uploadLoading.value = true
  135. try {
  136. const file = options.file as File
  137. const res: any = await attachmentApi.upload(file, 'advance_issue', currentRow.value.id)
  138. const fileData: { id: number; fileName: string; fileType: string; fileSize: number; previewUrl: string } = {
  139. id: res.data.id,
  140. fileName: res.data.fileName,
  141. fileType: res.data.fileType || '',
  142. fileSize: res.data.fileSize,
  143. previewUrl: '',
  144. }
  145. if (isImageFile(fileData.fileType)) {
  146. try {
  147. const previewRes: any = await attachmentApi.preview(fileData.id)
  148. fileData.previewUrl = previewRes.data.url
  149. } catch { /* preview not critical */ }
  150. }
  151. uploadedFiles.value.push(fileData)
  152. } catch (e: any) {
  153. ElMessage.error(e.message || '上传失败')
  154. } finally {
  155. uploadLoading.value = false
  156. }
  157. }
  158. async function handleRemoveAttachment(index: number) {
  159. const file = uploadedFiles.value[index]
  160. try {
  161. await ElMessageBox.confirm('确定删除该附件?', '提示', { type: 'warning' })
  162. await attachmentApi.delete(file.id)
  163. uploadedFiles.value.splice(index, 1)
  164. ElMessage.success('已删除')
  165. } catch { /* cancelled or already errored */ }
  166. }
  167. async function handleDownload(file: { id: number; fileName: string }) {
  168. try {
  169. const res: any = await attachmentApi.download(file.id)
  170. const blob = new Blob([res], { type: 'application/octet-stream' })
  171. const url = URL.createObjectURL(blob)
  172. const a = document.createElement('a')
  173. a.href = url
  174. a.download = file.fileName
  175. a.click()
  176. URL.revokeObjectURL(url)
  177. } catch (e: any) {
  178. ElMessage.error(e.message || '下载失败')
  179. }
  180. }
  181. async function handlePreview(file: { id: number; fileName: string; fileType: string }) {
  182. const isImage = file.fileType?.startsWith('image/')
  183. if (!isImage) {
  184. handleDownload(file)
  185. return
  186. }
  187. try {
  188. const res: any = await attachmentApi.preview(file.id)
  189. previewUrl.value = res.data.url
  190. previewFileName.value = file.fileName
  191. previewVisible.value = true
  192. } catch (e: any) {
  193. ElMessage.error(e.message || '预览失败')
  194. }
  195. }
  196. function formatFileSize(bytes: number): string {
  197. if (bytes < 1024) return bytes + ' B'
  198. if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
  199. return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
  200. }
  201. function isImageFile(fileType: string): boolean {
  202. return fileType?.startsWith('image/')
  203. }
  204. function fileExt(name: string): string {
  205. const ext = name.split('.').pop()
  206. return ext ? ext.toUpperCase() : 'FILE'
  207. }
  208. async function submitIssue() {
  209. loadingMap.issueSubmit = true
  210. try {
  211. await paymentApi.issueAdvance(currentRow.value.id, {
  212. issueRemark: issueRemark.value,
  213. actualAmount: actualAmount.value !== '' ? Number(actualAmount.value) : undefined,
  214. })
  215. ElMessage.success('发放成功')
  216. dialogs.issue = false
  217. await refresh()
  218. } catch (e: any) {
  219. ElMessage.error(e.message || '发放失败')
  220. } finally {
  221. loadingMap.issueSubmit = false
  222. }
  223. }
  224. async function openView(row: any) {
  225. try {
  226. viewData.value = row
  227. viewAttachments.value = []
  228. approvalNodes.value = []
  229. offsetRecords.value = []
  230. // Load attachments for issued records
  231. if (row.issueStatus === 'issued' && row.id) {
  232. const res: any = await attachmentApi.listByBiz('advance_issue', row.id)
  233. const attachments = (res.data || []).map((a: any) => ({ ...a, previewUrl: '' }))
  234. const imagePromises = attachments
  235. .filter((a: any) => isImageFile(a.fileType || ''))
  236. .map(async (a: any) => {
  237. try {
  238. const pRes: any = await attachmentApi.preview(a.id)
  239. a.previewUrl = pRes.data.url
  240. } catch { /* skip */ }
  241. })
  242. await Promise.all(imagePromises)
  243. viewAttachments.value = attachments
  244. }
  245. // Load approval flow
  246. if (row.id) {
  247. approvalLoading.value = true
  248. try {
  249. const [aRes, offsetRes]: any[] = await Promise.all([
  250. advanceApi.getApprovals(row.id),
  251. paymentApi.advanceOffsetRecords(row.id).catch(() => ({ data: [] })),
  252. ])
  253. approvalNodes.value = aRes.data || []
  254. offsetRecords.value = offsetRes.data || []
  255. } catch { /* skip */ }
  256. finally {
  257. approvalLoading.value = false
  258. }
  259. }
  260. dialogs.view = true
  261. } catch (e: any) {
  262. ElMessage.error(e.message || '加载详情失败')
  263. }
  264. }
  265. </script>
  266. <template>
  267. <div>
  268. <div class="page-head">
  269. <h1>备用金发放管理</h1>
  270. <p class="desc">审批通过后由财务人员操作标记发放并上传发放截图。</p>
  271. </div>
  272. <div class="stats">
  273. <div v-for="item in stats" :key="item.name" class="stat">
  274. <p class="k">{{ item.name }}</p>
  275. <p class="v">{{ item.value }}</p>
  276. </div>
  277. </div>
  278. <el-tabs v-model="activeTab" class="issue-tabs" @tab-change="handleTabChange">
  279. <el-tab-pane label="待发放" name="pending" />
  280. <el-tab-pane label="已发放" name="issued" />
  281. </el-tabs>
  282. <ListPageScaffold
  283. panel-title=""
  284. :show-more="showMoreFilters"
  285. :selected-count="selectedRows.length"
  286. :rows="rows"
  287. :range-text="rangeText"
  288. :total="total"
  289. :current-page="currentPage"
  290. :page-size="pageSize"
  291. :loading="loading"
  292. :table-max-height="'min(520px, calc(100vh - 360px))'"
  293. @toggle-more="toggleMoreFilters"
  294. @reset="handleReset"
  295. @search="handleSearch"
  296. @update:current-page="currentPage = $event"
  297. @update:page-size="pageSize = $event"
  298. @size-change="onSizeChange"
  299. @selection-change="onSelectionChange"
  300. >
  301. <template #filters>
  302. <div class="filter-grid">
  303. <div class="filter-field">
  304. <label>申请单号</label>
  305. <BaseInput v-model="filterForm.keyword" />
  306. </div>
  307. <div class="filter-field">
  308. <label>申请人</label>
  309. <BaseInput v-model="filterForm.applicant" />
  310. </div>
  311. </div>
  312. </template>
  313. <template #toolbar>
  314. <BaseButton v-if="activeTab === 'pending'" type="primary" plain :icon="Check" @click="handleToolbarIssue">标记已发放</BaseButton>
  315. <BaseButton :loading="loadingMap.export" :icon="Download" @click="handleExport">导出</BaseButton>
  316. </template>
  317. <template #table-columns>
  318. <el-table-column type="selection" width="48" />
  319. <el-table-column type="index" label="序号" width="70" />
  320. <el-table-column prop="applyCode" label="申请单号" min-width="140" />
  321. <el-table-column prop="applicantName" label="申请人" min-width="100" />
  322. <el-table-column prop="reason" label="申请原因" min-width="160" />
  323. <el-table-column prop="approveTime" label="审批通过时间" min-width="140" />
  324. <el-table-column v-if="activeTab === 'issued'" prop="issueTime" label="发放时间" min-width="140">
  325. <template #default="{ row }">
  326. {{ row.issueTime ?? '-' }}
  327. </template>
  328. </el-table-column>
  329. <el-table-column v-if="activeTab === 'issued'" prop="issueUserName" label="发放操作人" min-width="110">
  330. <template #default="{ row }">
  331. {{ row.issueUserName ?? '-' }}
  332. </template>
  333. </el-table-column>
  334. <el-table-column label="操作" :width="activeTab === 'pending' ? '260' : '140'" fixed="right" class-name="col-actions">
  335. <template #default="{ row }">
  336. <BaseOpButton v-if="activeTab === 'pending'" :icon="Check" @click="openIssueDialog(row)">
  337. 标记发放
  338. </BaseOpButton>
  339. <BaseOpButton primary :icon="View" @click="openView(row)">
  340. 查看详情
  341. </BaseOpButton>
  342. </template>
  343. </el-table-column>
  344. </template>
  345. </ListPageScaffold>
  346. <!-- 标记发放弹窗 -->
  347. <BaseDialog v-model="dialogs.issue" title="标记已发放" width="600px">
  348. <div class="form-section">
  349. <div class="form-grid-2">
  350. <div class="field">
  351. <label>发放金额(元)</label>
  352. <BaseInput :model-value="String(currentRow.amount || '')" disabled placeholder="发放金额" />
  353. </div>
  354. <div class="field">
  355. <label>实际发放金额(元)</label>
  356. <BaseInput v-model="actualAmount" placeholder="请输入实际发放金额" />
  357. </div>
  358. </div>
  359. </div>
  360. <div class="form-section">
  361. <h4>发放备注</h4>
  362. <div class="field field-no-margin">
  363. <label class="sr-only">发放备注</label>
  364. <BaseInput v-model="issueRemark" type="textarea" :rows="3" placeholder="请输入发放备注" />
  365. </div>
  366. </div>
  367. <div class="form-section form-section-last">
  368. <h4>发放附件</h4>
  369. <div class="attachment-upload-area">
  370. <el-upload
  371. :http-request="handleUpload"
  372. :show-file-list="false"
  373. multiple
  374. :disabled="uploadLoading"
  375. >
  376. <div class="upload-trigger">
  377. <el-icon class="upload-icon"><Plus /></el-icon>
  378. <p>点击上传附件</p>
  379. </div>
  380. </el-upload>
  381. <div v-if="uploadedFiles.length" class="attachment-grid">
  382. <div v-for="(file, idx) in uploadedFiles" :key="file.id" class="attachment-card">
  383. <div class="card-preview" @click="isImageFile(file.fileType) && handlePreview(file)">
  384. <img v-if="isImageFile(file.fileType) && file.previewUrl" :src="file.previewUrl" :alt="file.fileName" />
  385. <div v-else class="card-file-icon">
  386. <el-icon :size="32"><Document /></el-icon>
  387. <span class="card-ext">{{ fileExt(file.fileName) }}</span>
  388. </div>
  389. </div>
  390. <div class="card-footer">
  391. <el-button size="small" text @click="handleRemoveAttachment(idx)"><el-icon><Delete /></el-icon> 删除</el-button>
  392. <el-button size="small" text @click="handleDownload(file)"><el-icon><Download /></el-icon> 下载</el-button>
  393. </div>
  394. </div>
  395. </div>
  396. </div>
  397. </div>
  398. <template #footer>
  399. <BaseButton :icon="Close" @click="dialogs.issue = false">取消</BaseButton>
  400. <BaseButton type="primary" :loading="loadingMap.issueSubmit" :icon="Check" @click="submitIssue">确认发放</BaseButton>
  401. </template>
  402. </BaseDialog>
  403. <!-- 图片预览弹窗 -->
  404. <el-dialog v-model="previewVisible" title="图片预览" width="700px" class="bx-dialog" align-center>
  405. <div class="img-preview-wrap">
  406. <img :src="previewUrl" :alt="previewFileName" class="img-preview" />
  407. </div>
  408. </el-dialog>
  409. <!-- 查看详情弹窗 -->
  410. <BaseDialog v-model="dialogs.view" title="备用金发放详情" width="920px" class="dialog-view-xl">
  411. <div class="dlg-view dlg-view-scroll">
  412. <div class="dlg-view-hero">
  413. <div class="dlg-view-hero-top">
  414. <h2 class="dlg-view-title">{{ viewData.applyCode || '-' }}</h2>
  415. <div class="dlg-view-tags">
  416. <BaseTag :tone="issueStatusTone(viewData.issueStatus)">{{ issueStatusLabel(viewData.issueStatus) }}</BaseTag>
  417. </div>
  418. </div>
  419. <p class="dlg-view-meta">
  420. 申请金额(元):<b class="money-em">¥{{ Number(viewData.amount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 }) }}</b>
  421. <template v-if="viewData.actualAmount != null">
  422. &nbsp;&nbsp;实际发放金额(元):<b class="money-em">¥{{ Number(viewData.actualAmount).toLocaleString('zh-CN', { minimumFractionDigits: 2 }) }}</b>
  423. </template>
  424. </p>
  425. </div>
  426. <div class="dlg-view-section">
  427. <h4>申请信息</h4>
  428. <div class="dlg-kv dlg-kv--triple">
  429. <div class="k">申请人</div>
  430. <div class="v">{{ viewData.applicantName || '-' }}</div>
  431. <div class="k">申请原因</div>
  432. <div class="v"><BaseClampText :text="viewData.reason" /></div>
  433. <div class="k">审批通过时间</div>
  434. <div class="v">{{ viewData.approveTime || '-' }}</div>
  435. </div>
  436. </div>
  437. <div class="dlg-view-section">
  438. <h4>审批流程</h4>
  439. <div class="flow-scroll">
  440. <ApprovalFlowView :nodes="approvalNodes" :loading="approvalLoading" />
  441. </div>
  442. </div>
  443. <div v-if="viewData.issueStatus === 'issued'" class="dlg-view-section">
  444. <h4>发放信息</h4>
  445. <div class="dlg-kv dlg-kv--triple">
  446. <div class="k">发放时间</div>
  447. <div class="v">{{ viewData.issueTime || '-' }}</div>
  448. <div class="k">发放操作人</div>
  449. <div class="v">{{ viewData.issueUserName || '-' }}</div>
  450. <div class="k">发放备注</div>
  451. <div class="v kv-full"><BaseClampText :text="viewData.issueRemark" /></div>
  452. </div>
  453. </div>
  454. <BaseDetailTableSection title="冲抵流水" :data="offsetRecords" empty-text="暂无冲抵记录">
  455. <el-table :data="offsetRecords" size="small" border>
  456. <el-table-column prop="reimburseApplyCode" label="报销单号" min-width="130" />
  457. <el-table-column prop="reimburseApplicantName" label="报销申请人" min-width="100" />
  458. <el-table-column label="冲抵金额(元)" min-width="120" align="right">
  459. <template #default="{ row: offsetRow }">
  460. {{ Number(offsetRow.offsetAmount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 }) }}
  461. </template>
  462. </el-table-column>
  463. <el-table-column prop="createTime" label="冲抵时间" min-width="160" />
  464. <el-table-column prop="operator" label="操作人" min-width="100" />
  465. <el-table-column prop="remark" label="备注" min-width="140" show-overflow-tooltip />
  466. </el-table>
  467. </BaseDetailTableSection>
  468. <div v-if="viewAttachments.length" class="dlg-view-section">
  469. <h4>发放附件</h4>
  470. <div class="attachment-grid">
  471. <div v-for="file in viewAttachments" :key="file.id" class="attachment-card">
  472. <div class="card-preview" @click="isImageFile(file.fileType || '') && handlePreview(file)">
  473. <img v-if="isImageFile(file.fileType || '') && file.previewUrl" :src="file.previewUrl" :alt="file.fileName" />
  474. <div v-else class="card-file-icon">
  475. <el-icon :size="32"><Document /></el-icon>
  476. <span class="card-ext">{{ fileExt(file.fileName) }}</span>
  477. </div>
  478. </div>
  479. <div class="card-footer">
  480. <el-button size="small" text @click="handleDownload(file)"><el-icon><Download /></el-icon> 下载</el-button>
  481. </div>
  482. </div>
  483. </div>
  484. </div>
  485. </div>
  486. </BaseDialog>
  487. </div>
  488. </template>
  489. <style scoped>
  490. .dlg-view-scroll {
  491. max-height: 60vh;
  492. overflow-y: auto;
  493. }
  494. .attachment-upload-area {
  495. margin-top: 4px;
  496. }
  497. .upload-trigger {
  498. width: 100%;
  499. border: 1px dashed var(--border);
  500. border-radius: var(--radius-md);
  501. padding: 20px;
  502. text-align: center;
  503. cursor: pointer;
  504. transition: border-color 0.2s;
  505. color: var(--text-muted);
  506. }
  507. .upload-trigger:hover {
  508. border-color: var(--primary);
  509. color: var(--primary-dark);
  510. }
  511. .upload-trigger .upload-icon {
  512. font-size: 24px;
  513. margin-bottom: 6px;
  514. }
  515. .upload-trigger p {
  516. margin: 0;
  517. font-size: 13px;
  518. }
  519. .attachment-grid {
  520. margin-top: 10px;
  521. display: grid;
  522. grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
  523. gap: 12px;
  524. }
  525. .attachment-card {
  526. border: 1px solid var(--border);
  527. border-radius: var(--radius-md);
  528. overflow: hidden;
  529. background: var(--surface);
  530. transition: box-shadow 0.2s;
  531. }
  532. .attachment-card:hover {
  533. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
  534. }
  535. .card-preview {
  536. width: 100%;
  537. aspect-ratio: 1;
  538. display: flex;
  539. align-items: center;
  540. justify-content: center;
  541. overflow: hidden;
  542. background: #f8fafc;
  543. cursor: default;
  544. }
  545. .card-preview img {
  546. width: 100%;
  547. height: 100%;
  548. object-fit: cover;
  549. }
  550. .card-preview:has(img) {
  551. cursor: pointer;
  552. }
  553. .card-file-icon {
  554. display: flex;
  555. flex-direction: column;
  556. align-items: center;
  557. justify-content: center;
  558. gap: 4px;
  559. color: var(--text-muted);
  560. }
  561. .card-ext {
  562. font-size: 11px;
  563. font-weight: 600;
  564. color: var(--text-muted);
  565. }
  566. .card-footer {
  567. display: flex;
  568. align-items: center;
  569. justify-content: center;
  570. gap: 2px;
  571. padding: 4px 0;
  572. border-top: 1px solid var(--border);
  573. background: var(--surface);
  574. }
  575. .card-footer .el-button {
  576. font-size: 12px;
  577. padding: 4px 8px;
  578. height: auto;
  579. }
  580. /* Tab styles */
  581. :deep(.issue-tabs .el-tabs__header) {
  582. margin-bottom: 0;
  583. }
  584. :deep(.issue-tabs .el-tabs__nav-wrap::after) {
  585. display: none;
  586. }
  587. :deep(.issue-tabs .el-tabs__item) {
  588. font-size: 15px;
  589. font-weight: 600;
  590. color: var(--text-muted);
  591. padding: 0 20px;
  592. height: 44px;
  593. line-height: 44px;
  594. }
  595. :deep(.issue-tabs .el-tabs__item.is-active) {
  596. color: var(--primary-dark);
  597. }
  598. :deep(.issue-tabs .el-tabs__active-bar) {
  599. background-color: var(--primary);
  600. height: 3px;
  601. border-radius: 2px;
  602. }
  603. :deep(.issue-tabs .el-tabs__item:hover) {
  604. color: var(--text);
  605. }
  606. </style>