You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

385 regels
16 KiB

  1. {% extends "../layout.html" %}
  2. {% block title %}{{ columnInfo.name if columnInfo else '捐赠收支' }}{% endblock %}
  3. {% block content %}
  4. <div id="donationApp">
  5. <!-- 统计卡片 -->
  6. <div class="stat-row">
  7. <div class="stat-card">
  8. <div class="stat-label">累计捐赠收入</div>
  9. <div class="stat-value" style="color:#ff7800;">${ formatMoney(stat.total_income) }</div>
  10. </div>
  11. <div class="stat-card">
  12. <div class="stat-label">累计公益支出</div>
  13. <div class="stat-value" style="color:#1A3550;">${ formatMoney(stat.total_expense) }</div>
  14. </div>
  15. <div class="stat-card">
  16. <div class="stat-label">本年度收入</div>
  17. <div class="stat-value" style="color:#67c23a;">${ formatMoney(stat.year_income) }</div>
  18. </div>
  19. <div class="stat-card">
  20. <div class="stat-label">本年度支出</div>
  21. <div class="stat-value" style="color:#e6a23c;">${ formatMoney(stat.year_expense) }</div>
  22. </div>
  23. </div>
  24. <!-- 统计操作栏 -->
  25. <el-card shadow="never" class="mb-4">
  26. <div class="flex items-center gap-3">
  27. <span class="text-sm">统计数据管理</span>
  28. <el-button size="small" @click="openStatDialog">编辑统计</el-button>
  29. <el-button size="small" type="primary" @click="syncStat" :loading="syncing">从明细同步</el-button>
  30. <span v-if="stat.sync_time" class="text-xs text-gray-400 ml-2">上次同步: ${ stat.sync_time?.slice(0,16).replace('T',' ') }</span>
  31. </div>
  32. </el-card>
  33. <!-- Tab 切换 -->
  34. <div class="content-tabs mb-4">
  35. <div class="tab-item" :class="{ active: activeTab === 'income' }" @click="switchTab('income')">捐赠收入</div>
  36. <div class="tab-item" :class="{ active: activeTab === 'expense' }" @click="switchTab('expense')">公益支出</div>
  37. </div>
  38. <el-card shadow="never">
  39. <!-- 筛选栏 -->
  40. <div class="flex items-center gap-4 mb-4 flex-wrap">
  41. <el-input v-model="query.keyword" :placeholder="activeTab === 'income' ? '捐赠人/单位...' : '支出项目...'" style="width:180px;" clearable @keyup.enter="loadList"></el-input>
  42. <el-select v-model="query.source" placeholder="来源" style="width:120px;" clearable>
  43. <el-option label="金蝶同步" value="kingdee"></el-option>
  44. <el-option label="手动录入" value="manual"></el-option>
  45. </el-select>
  46. <el-select v-model="query.status" placeholder="状态" style="width:100px;" clearable>
  47. <el-option label="已公示" :value="1"></el-option>
  48. <el-option label="待公示" :value="0"></el-option>
  49. </el-select>
  50. <el-button type="primary" @click="loadList">搜索</el-button>
  51. <el-button @click="resetQuery">重置</el-button>
  52. <div class="flex-1"></div>
  53. <el-button type="primary" @click="openDialog()">+ 手动录入</el-button>
  54. <el-button type="success" @click="exportData">导出</el-button>
  55. </div>
  56. <!-- 表格 -->
  57. <el-table :data="list" v-loading="loading" border>
  58. <el-table-column :label="activeTab === 'income' ? '捐赠人/单位' : '支出项目'" prop="name" min-width="200"></el-table-column>
  59. <el-table-column label="金额 (元)" width="160">
  60. <template #default="{ row }">
  61. <span :style="{ fontWeight: 600, color: activeTab === 'income' ? '#ff7800' : '#1A3550' }">${ formatMoney(row.amount) }</span>
  62. </template>
  63. </el-table-column>
  64. <el-table-column :label="activeTab === 'income' ? '用途/项目' : '受益对象'" prop="purpose" min-width="140"></el-table-column>
  65. <el-table-column label="来源" width="100" align="center">
  66. <template #default="{ row }">
  67. <el-tag :type="row.source === 'kingdee' ? 'primary' : 'info'" size="small">${ row.source === 'kingdee' ? '金蝶同步' : '手动录入' }</el-tag>
  68. </template>
  69. </el-table-column>
  70. <el-table-column label="日期" width="120" prop="record_date"></el-table-column>
  71. <el-table-column label="状态" width="90" align="center">
  72. <template #default="{ row }">
  73. <el-tag :type="row.status ? 'success' : 'warning'" size="small">${ row.status ? '已公示' : '待公示' }</el-tag>
  74. </template>
  75. </el-table-column>
  76. <el-table-column label="操作" width="120" fixed="right">
  77. <template #default="{ row }">
  78. <el-button type="primary" link size="small" @click="openDialog(row)">编辑</el-button>
  79. <el-button type="danger" link size="small" @click="deleteItem(row)">删除</el-button>
  80. </template>
  81. </el-table-column>
  82. </el-table>
  83. <!-- 分页 -->
  84. <div class="flex justify-end mt-4" v-if="total > pageSize">
  85. <el-pagination background layout="prev, pager, next" :total="total" :page-size="pageSize" v-model:current-page="page" @current-change="loadList"></el-pagination>
  86. </div>
  87. </el-card>
  88. <!-- 新增/编辑弹窗 -->
  89. <el-dialog v-model="dialogVisible" :title="dialogTitle" width="560px" destroy-on-close draggable top="5vh">
  90. <el-form :model="form" label-width="100px">
  91. <el-form-item label="类型">
  92. <el-radio-group v-model="form.type">
  93. <el-radio value="income">捐赠收入</el-radio>
  94. <el-radio value="expense">公益支出</el-radio>
  95. </el-radio-group>
  96. </el-form-item>
  97. <el-form-item :label="form.type === 'income' ? '捐赠人/单位' : '支出项目'" required>
  98. <el-input v-model="form.name" placeholder="请输入"></el-input>
  99. </el-form-item>
  100. <div class="flex gap-4">
  101. <el-form-item label="金额 (元)" required class="flex-1">
  102. <el-input-number v-model="form.amount" :min="0" :precision="2" :controls="false" style="width:100%;"></el-input-number>
  103. </el-form-item>
  104. <el-form-item label="日期" class="flex-1">
  105. <el-date-picker v-model="form.record_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width:100%;"></el-date-picker>
  106. </el-form-item>
  107. </div>
  108. <el-form-item :label="form.type === 'income' ? '用途/项目' : '受益对象'">
  109. <el-input v-model="form.purpose" placeholder="请输入"></el-input>
  110. </el-form-item>
  111. <div class="flex gap-4">
  112. <el-form-item label="来源" class="flex-1">
  113. <el-select v-model="form.source" style="width:100%;">
  114. <el-option label="手动录入" value="manual"></el-option>
  115. <el-option label="金蝶同步" value="kingdee"></el-option>
  116. </el-select>
  117. </el-form-item>
  118. <el-form-item label="状态" class="flex-1">
  119. <el-select v-model="form.status" style="width:100%;">
  120. <el-option label="已公示" :value="1"></el-option>
  121. <el-option label="待公示" :value="0"></el-option>
  122. </el-select>
  123. </el-form-item>
  124. </div>
  125. </el-form>
  126. <template #footer>
  127. <el-button @click="dialogVisible = false">取消</el-button>
  128. <el-button type="primary" @click="saveItem" :loading="saving">保存</el-button>
  129. </template>
  130. </el-dialog>
  131. <!-- 统计编辑弹窗 -->
  132. <el-dialog v-model="statDialogVisible" title="编辑统计数据" width="480px" destroy-on-close draggable>
  133. <el-form :model="statForm" label-width="120px">
  134. <el-form-item label="累计捐赠收入">
  135. <el-input-number v-model="statForm.total_income" :min="0" :precision="2" :controls="false" style="width:100%;"></el-input-number>
  136. </el-form-item>
  137. <el-form-item label="累计公益支出">
  138. <el-input-number v-model="statForm.total_expense" :min="0" :precision="2" :controls="false" style="width:100%;"></el-input-number>
  139. </el-form-item>
  140. <el-form-item label="本年度收入">
  141. <el-input-number v-model="statForm.year_income" :min="0" :precision="2" :controls="false" style="width:100%;"></el-input-number>
  142. </el-form-item>
  143. <el-form-item label="本年度支出">
  144. <el-input-number v-model="statForm.year_expense" :min="0" :precision="2" :controls="false" style="width:100%;"></el-input-number>
  145. </el-form-item>
  146. </el-form>
  147. <template #footer>
  148. <el-button @click="statDialogVisible = false">取消</el-button>
  149. <el-button type="primary" @click="saveStat" :loading="statSaving">保存</el-button>
  150. </template>
  151. </el-dialog>
  152. </div>
  153. <style>
  154. .stat-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 16px; }
  155. .stat-card { background: #fff; border-radius: 4px; box-shadow: 0 2px 12px rgba(0,0,0,.06); padding: 20px; text-align: center; }
  156. .stat-label { font-size: 13px; color: #909399; margin-bottom: 8px; }
  157. .stat-value { font-size: 22px; font-weight: 700; font-family: "Roboto", sans-serif; }
  158. .content-tabs { display: flex; gap: 0; background: #fff; border-radius: 4px; overflow: hidden; box-shadow: 0 2px 12px rgba(0,0,0,.06); }
  159. .tab-item { padding: 12px 24px; font-size: 14px; cursor: pointer; color: #606266; transition: .2s; border-bottom: 2px solid transparent; }
  160. .tab-item:hover { color: #ff7800; }
  161. .tab-item.active { color: #ff7800; font-weight: 600; border-bottom-color: #ff7800; background: #fff7f0; }
  162. </style>
  163. {% endblock %}
  164. {% block js %}
  165. <!-- SheetJS for Excel export -->
  166. <script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
  167. <script>
  168. const col = '{{ col }}';
  169. const { createApp, ref, reactive, onMounted } = Vue;
  170. const app = createApp({
  171. delimiters: ['${', '}'],
  172. setup() {
  173. const loading = ref(false);
  174. const list = ref([]);
  175. const total = ref(0);
  176. const page = ref(1);
  177. const pageSize = ref(20);
  178. const activeTab = ref('income');
  179. const query = reactive({ keyword: '', source: '', status: '' });
  180. const stat = reactive({ total_income: 0, total_expense: 0, year_income: 0, year_expense: 0, sync_time: null });
  181. const syncing = ref(false);
  182. const dialogVisible = ref(false);
  183. const dialogTitle = ref('手动录入');
  184. const saving = ref(false);
  185. const defaultForm = { id: null, type: 'income', name: '', amount: 0, purpose: '', source: 'manual', record_date: '', status: 1 };
  186. const form = reactive({ ...defaultForm });
  187. const statDialogVisible = ref(false);
  188. const statSaving = ref(false);
  189. const statForm = reactive({ total_income: 0, total_expense: 0, year_income: 0, year_expense: 0 });
  190. function formatMoney(n) {
  191. return '¥ ' + Number(n || 0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
  192. }
  193. async function loadStat() {
  194. try {
  195. const res = await fetch('/admin/content/donation/stat?col=' + col).then(r => r.json());
  196. if (res.code === 0 && res.data) {
  197. Object.assign(stat, res.data);
  198. }
  199. } catch {}
  200. }
  201. async function loadList() {
  202. loading.value = true;
  203. try {
  204. const params = new URLSearchParams({ col, type: activeTab.value, page: page.value, pageSize: pageSize.value, ...query });
  205. const res = await fetch('/admin/content/donation/list?' + params).then(r => r.json());
  206. if (res.code === 0) {
  207. list.value = res.data.data || [];
  208. total.value = res.data.count || 0;
  209. }
  210. } finally { loading.value = false; }
  211. }
  212. function switchTab(tab) {
  213. activeTab.value = tab;
  214. page.value = 1;
  215. loadList();
  216. }
  217. function resetQuery() {
  218. query.keyword = '';
  219. query.source = '';
  220. query.status = '';
  221. page.value = 1;
  222. loadList();
  223. }
  224. function openDialog(item) {
  225. if (item) {
  226. dialogTitle.value = '编辑记录';
  227. Object.assign(form, {
  228. id: item.id, type: item.type, name: item.name,
  229. amount: parseFloat(item.amount) || 0, purpose: item.purpose || '',
  230. source: item.source || 'manual', record_date: item.record_date || '',
  231. status: item.status
  232. });
  233. } else {
  234. dialogTitle.value = '手动录入';
  235. Object.assign(form, { ...defaultForm, type: activeTab.value });
  236. }
  237. dialogVisible.value = true;
  238. }
  239. async function saveItem() {
  240. if (!form.name.trim()) { ElementPlus.ElMessage.warning('请输入名称'); return; }
  241. if (!form.amount || form.amount <= 0) { ElementPlus.ElMessage.warning('请输入有效金额'); return; }
  242. saving.value = true;
  243. try {
  244. const url = form.id ? '/admin/content/donation/edit' : '/admin/content/donation/add';
  245. const body = { ...form, col };
  246. const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).then(r => r.json());
  247. if (res.code === 0) {
  248. ElementPlus.ElMessage.success('保存成功');
  249. dialogVisible.value = false;
  250. loadList();
  251. } else {
  252. ElementPlus.ElMessage.error(res.msg || '保存失败');
  253. }
  254. } finally { saving.value = false; }
  255. }
  256. async function deleteItem(item) {
  257. try {
  258. await ElementPlus.ElMessageBox.confirm('确定删除"' + item.name + '"?', '提示', { type: 'warning' });
  259. const res = await fetch('/admin/content/donation/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: item.id }) }).then(r => r.json());
  260. if (res.code === 0) {
  261. ElementPlus.ElMessage.success('删除成功');
  262. loadList();
  263. } else {
  264. ElementPlus.ElMessage.error(res.msg || '删除失败');
  265. }
  266. } catch {}
  267. }
  268. function openStatDialog() {
  269. Object.assign(statForm, {
  270. total_income: parseFloat(stat.total_income) || 0,
  271. total_expense: parseFloat(stat.total_expense) || 0,
  272. year_income: parseFloat(stat.year_income) || 0,
  273. year_expense: parseFloat(stat.year_expense) || 0
  274. });
  275. statDialogVisible.value = true;
  276. }
  277. async function saveStat() {
  278. statSaving.value = true;
  279. try {
  280. const res = await fetch('/admin/content/donation/saveStat', {
  281. method: 'POST', headers: { 'Content-Type': 'application/json' },
  282. body: JSON.stringify({ ...statForm, col })
  283. }).then(r => r.json());
  284. if (res.code === 0) {
  285. ElementPlus.ElMessage.success('保存成功');
  286. Object.assign(stat, statForm);
  287. statDialogVisible.value = false;
  288. } else {
  289. ElementPlus.ElMessage.error(res.msg || '保存失败');
  290. }
  291. } finally { statSaving.value = false; }
  292. }
  293. async function syncStat() {
  294. try {
  295. await ElementPlus.ElMessageBox.confirm('确定从明细数据同步统计?这将覆盖当前统计数据。', '提示', { type: 'warning' });
  296. syncing.value = true;
  297. const res = await fetch('/admin/content/donation/syncStat', {
  298. method: 'POST', headers: { 'Content-Type': 'application/json' },
  299. body: JSON.stringify({ col })
  300. }).then(r => r.json());
  301. if (res.code === 0) {
  302. ElementPlus.ElMessage.success('同步成功');
  303. Object.assign(stat, res.data);
  304. } else {
  305. ElementPlus.ElMessage.error(res.msg || '同步失败');
  306. }
  307. } catch {} finally { syncing.value = false; }
  308. }
  309. async function exportData() {
  310. try {
  311. const res = await fetch('/admin/content/donation/export?col=' + col + '&type=' + activeTab.value).then(r => r.json());
  312. if (res.code === 0) {
  313. const data = res.data || [];
  314. const isIncome = activeTab.value === 'income';
  315. const headers = isIncome
  316. ? ['捐赠人/单位', '金额', '用途/项目', '来源', '日期', '状态']
  317. : ['支出项目', '金额', '受益对象', '来源', '日期', '状态'];
  318. const rows = data.map(item => [
  319. item.name,
  320. parseFloat(item.amount) || 0,
  321. item.purpose,
  322. item.source === 'kingdee' ? '金蝶同步' : '手动录入',
  323. item.record_date,
  324. item.status ? '已公示' : '待公示'
  325. ]);
  326. const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]);
  327. const wb = XLSX.utils.book_new();
  328. XLSX.utils.book_append_sheet(wb, ws, isIncome ? '捐赠收入' : '公益支出');
  329. XLSX.writeFile(wb, (isIncome ? '捐赠收入' : '公益支出') + '_' + new Date().toISOString().slice(0,10) + '.xlsx');
  330. ElementPlus.ElMessage.success('导出成功');
  331. }
  332. } catch (err) {
  333. ElementPlus.ElMessage.error('导出失败');
  334. }
  335. }
  336. onMounted(() => {
  337. loadStat();
  338. loadList();
  339. });
  340. return {
  341. loading, list, total, page, pageSize, activeTab, query, stat, syncing,
  342. dialogVisible, dialogTitle, saving, form,
  343. statDialogVisible, statSaving, statForm,
  344. formatMoney, loadList, switchTab, resetQuery, openDialog, saveItem, deleteItem,
  345. openStatDialog, saveStat, syncStat, exportData
  346. };
  347. }
  348. });
  349. app.use(ElementPlus, { locale: ElementPlusLocaleZhCn });
  350. app.mount('#donationApp');
  351. </script>
  352. {% endblock %}