This commit is contained in:
csj
2026-09-13 15:20:40 +08:00
parent 8bc9ae8046
commit da6cb24249
8 changed files with 566 additions and 4 deletions
+14
View File
@@ -1,5 +1,19 @@
# 管理端接入 # 管理端接入
## 已批准报销导出 PPT
报销审批页新增“导出 PPT”弹窗。自动分页加载当前项目/组别权限范围内的已批准申请,不局限于审批列表已加载的 100 条;支持按组别、账户和提交日期(含起止当天)筛选、逐条勾选、全选筛选结果及跨筛选累计选择。相同姓名通过账户 ID 区分。审批列表已有选择会带入弹窗,但待审核、退回的申请不会带入。
默认按项目/组别、姓名/账户、提交日期升序排列,也可改为人员优先或日期优先;并列记录按申请编号排序。弹窗列表与输出使用同一排序。底部显示已选数量、金额及当前筛选外的已选数量,避免切换筛选后误解导出范围。每批最多 100 笔,原始附件合计不超过 200 MB;分页超过 10,000 条时明确报错,需缩小组别范围,不静默截断。
导出前重新查询每笔申请的管理权限、批准状态和版本,附件仍经原有鉴权下载接口获取。复用本地贴票的 1280×720 PPT 版式:每笔附归属信息页,发票逐页大图、付款凭证最多四张一页;已上传照片与自定义材料自动贴入,PDF 全页展开(单份最多 100 页、合计最多 1,000 个材料页,超过时拒绝并提示分批)。不同申请的凭证不混排;没有附件时明确注明。日期是提交日期,不是审批时间或票面时间。
可取消、失败后保留选择、成功后在 Finder 中显示文件。中间文件使用独立临时目录并清理,完成后才原子写入所选位置;不改动本地贴票工作区,不执行 OCR 自动匹配,也不改变报销状态。原本地贴票导出逻辑保持不变。
本功能不需改后端或迁移数据库。因新增本地引擎指令 `approved-ppt`,更新后必须先运行 `bash native-engine/build-engine.sh` 再重新构建 Mac 应用,不能只更新 Swift 界面而沿用旧引擎。
回归验证:`.build-tools/bin/python -m unittest discover -s native-engine/tests -v`;使用匹配的 Xcode 工具链运行 `swiftc reimburse/AdminModels.swift tests/AdminPPTSelectionTests.swift -o /tmp/admin-ppt-selection-tests && /tmp/admin-ppt-selection-tests`。真机需验收多页 PDF、同名不同账户、跨组勾选、网络失败、取消及保存目录权限。
## 报销类型页面布局 ## 报销类型页面布局
报销类型页面采用左侧搜索/选择类型、右侧编辑规则的分栏布局。类型列表和规则区域分别滚动,底部保存栏始终可见;保存成功显示反馈,继续修改后恢复保存提示。新增类型固定在左下方,重命名/删除集中在详情标题的更多菜单中。 报销类型页面采用左侧搜索/选择类型、右侧编辑规则的分栏布局。类型列表和规则区域分别滚动,底部保存栏始终可见;保存成功显示反馈,继续修改后恢复保存提示。新增类型固定在左下方,重命名/删除集中在详情标题的更多菜单中。
+2 -2
View File
@@ -86,12 +86,12 @@ def dispatch(request):
state = request['state'] state = request['state']
if operation == 'refresh': if operation == 'refresh':
return enrich(state) return enrich(state)
if not state['matches']: if not (state.get('approvedRecords') if operation == 'approved-ppt' else state.get('matches')):
raise ValueError('至少完成一组核对后才能导出') raise ValueError('至少完成一组核对后才能导出')
destination = Path(request['destination']) destination = Path(request['destination'])
temporary = destination.with_name('.' + str(uuid.uuid4()) + destination.suffix) temporary = destination.with_name('.' + str(uuid.uuid4()) + destination.suffix)
try: try:
if operation == 'ppt': if operation in ('ppt', 'approved-ppt'):
export_ppt(state, temporary, request.get('classified', True)) export_ppt(state, temporary, request.get('classified', True))
elif operation == 'travel': elif operation == 'travel':
export_travel(state, temporary) export_travel(state, temporary)
+84 -2
View File
@@ -42,12 +42,13 @@ def export_ppt(state, destination, classified):
def payment_page(payments): def payment_page(payments):
page = slide() page = slide()
height = 660 if 'approvedRecords' in state else 680
if len(payments) == 1: if len(payments) == 1:
picture(page, payments[0], (300, 20, 680, 680)) picture(page, payments[0], (300, 20, 680, height))
else: else:
width = (1200 - 24 * (len(payments) - 1)) // len(payments) width = (1200 - 24 * (len(payments) - 1)) // len(payments)
for index, item in enumerate(payments): for index, item in enumerate(payments):
picture(page, item, (40 + index * (width + 24), 20, width, 680)) picture(page, item, (40 + index * (width + 24), 20, width, height))
def label(page, box, value, size, title=False): def label(page, box, value, size, title=False):
shape = page.shapes.add_shape(MSO_SHAPE.RECTANGLE, *(Pt(value) for value in box)) shape = page.shapes.add_shape(MSO_SHAPE.RECTANGLE, *(Pt(value) for value in box))
@@ -83,6 +84,87 @@ def export_ppt(state, destination, classified):
payment_page([match['payments'][0] for match in batch]) payment_page([match['payments'][0] for match in batch])
photos(sum(len(match['payments']) for match in batch if requires_photo(match))) photos(sum(len(match['payments']) for match in batch if requires_photo(match)))
if 'approvedRecords' in state:
import contextlib
import tempfile
import pypdfium2 as pdfium
from pptx.enum.text import MSO_AUTO_SIZE
records = state['approvedRecords']
if not records or len(records) > 100:
raise ValueError('每批请选择 1 至 100 笔已批准申请')
with tempfile.TemporaryDirectory(prefix='approved-ppt-pages-', dir=Path(destination).parent) as directory:
page_count = 0
for record in records:
summary = slide()
label(summary, (55, 28, 1170, 64), '已批准报销 · #' + record['id'], 26, True)
details = [record['projectName'] + ' / ' + record['team'],
record['name'] + ' · ' + record['type'],
'提交日期:' + record['date'] + ' 报销金额:¥' + record['amountText']]
for index, detail in enumerate(details):
label(summary, (55, 120 + index * 70, 1170, 60), detail, 22)
note = record.get('note', '').strip()
for offset in range(0, len(note), 240):
note_page = summary if offset == 0 else slide()
label(note_page, (55, 360 if offset == 0 else 100, 1170, 300),
('备注:' if offset == 0 else '备注(续):') + note[offset:offset + 240], 18)
groups = {}
kind_names = {}
for material in record['materials']:
kind_names[material['kind']] = material.get('kindName', '其他材料')
path = Path(material['path'])
images = []
try:
if path.suffix.lower() == '.pdf':
with contextlib.closing(pdfium.PdfDocument(str(path))) as document:
if not 0 < len(document) <= 100:
raise ValueError('单份 PDF 需为 1–100 页,请拆分后导出')
for page_index in range(len(document)):
with contextlib.closing(document[page_index]) as pdf_page:
scale = min(140 / 72, 2600 / max(pdf_page.get_size()))
with contextlib.closing(pdf_page.render(scale=scale)) as bitmap:
preview = Path(directory) / f'{page_count}.png'
bitmap.to_pil().convert('RGB').save(preview)
images.append({'path': str(preview)})
page_count += 1
else:
with Image.open(path) as image:
image.verify()
images.append(material)
page_count += 1
except Exception as error:
raise ValueError(f"申请 #{record['id']} 的“{material['name']}”无法导出:{error}") from error
if page_count > 1000:
raise ValueError('材料展开后超过 1,000 页,请减少选择并分批导出')
groups.setdefault(material['kind'], []).extend(images)
if not groups:
label(summary, (55, 660, 1170, 36), '该申请没有上传附件', 16)
for item in groups.pop('invoice', []):
picture(slide(), item, (40, 25, 1200, 670))
payments = groups.pop('payment', [])
for offset in range(0, len(payments), 4):
payment_page(payments[offset:offset + 4])
for kind, materials in sorted(groups.items()):
for offset in range(0, len(materials), 4):
page = slide()
title = '实拍照片' if kind in ('receipt', 'photo') else kind_names[kind]
label(page, (55, 20, 1170, 50), title, 20, True)
batch = materials[offset:offset + 4]
for index, item in enumerate(batch):
box = (250, 90, 780, 570) if len(batch) == 1 else (55 + index % 2 * 610, 90 + index // 2 * 290, 560, 270)
picture(page, item, box)
start = list(presentation.slides).index(summary)
for page in list(presentation.slides)[start + 1:]:
label(page, (40, 696, 1200, 22),
f"{record['projectName']} / {record['team']} · {record['name']} · {record['date']} · #{record['id']}", 10, True)
for page in presentation.slides:
for shape in page.shapes:
if shape.has_text_frame:
shape.text_frame.word_wrap = True
shape.text_frame.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE
presentation.save(destination)
return
matches = sorted(state['matches'], key=lambda match: (min(material_date(item, True) for item in match['invoices']), match['category'] if classified else '')) matches = sorted(state['matches'], key=lambda match: (min(material_date(item, True) for item in match['invoices']), match['category'] if classified else ''))
batch = [] batch = []
for match in matches: for match in matches:
+101
View File
@@ -0,0 +1,101 @@
import copy
import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from PIL import Image
from pptx import Presentation
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from engine import dispatch
from exports import export_ppt
class ApprovedPPTTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.root = Path(self.temporary.name)
self.image = self.root / 'image.png'
Image.new('RGB', (600, 400), 'white').save(self.image)
self.record = dict(id='2', projectName='项目甲', team='摄影组', name='张三',
date='2026-09-13', type='道具耗材', amountText='123.45', note='采购材料', materials=[])
def tearDown(self):
self.temporary.cleanup()
def material(self, kind, path=None):
return dict(kind=kind, path=str(path or self.image), name='测试材料')
def export(self, records):
destination = self.root / 'approved.pptx'
dispatch(dict(operation='approved-ppt', state=dict(approvedRecords=records), destination=str(destination)))
return Presentation(destination)
def test_all_materials_and_payment_pagination(self):
self.record['materials'] = [self.material('invoice')] + [self.material('payment')] * 5 + [self.material('receipt'), self.material('custom')]
deck = self.export([self.record])
self.assertEqual(len(deck.slides), 6)
pictures = [shape for page in deck.slides for shape in page.shapes if shape.shape_type == 13]
self.assertEqual(len(pictures), 8)
self.assertTrue(all('#2' in '\n'.join(shape.text for shape in page.shapes if shape.has_text_frame) for page in deck.slides))
self.assertFalse(any('请手动粘贴' in shape.text for page in deck.slides for shape in page.shapes if shape.has_text_frame))
def test_multipage_pdf_exports_every_page(self):
pdf = self.root / 'invoice.pdf'
first = Image.new('RGB', (300, 200), 'white')
second = Image.new('RGB', (300, 200), 'blue')
first.save(pdf, save_all=True, append_images=[second])
self.record['materials'] = [self.material('invoice', pdf)]
self.assertEqual(len(self.export([self.record]).slides), 3)
def test_preserves_selected_order_without_merging_people(self):
other = copy.deepcopy(self.record)
other.update(id='1', name='李四', date='2026-09-01')
self.record['materials'] = [self.material('payment')]
other['materials'] = [self.material('payment')]
deck = self.export([self.record, other])
self.assertEqual(len(deck.slides), 4)
self.assertIn('#2', deck.slides[0].shapes[0].text)
self.assertIn('#1', deck.slides[2].shapes[0].text)
def test_empty_materials_and_long_note_not_silently_lost(self):
self.record['note'] = '采购备注' * 250
deck = self.export([self.record])
text = '\n'.join(shape.text for page in deck.slides for shape in page.shapes if shape.has_text_frame)
self.assertEqual(text.count('采购备注'), 250)
self.assertIn('该申请没有上传附件', text)
def test_failure_keeps_existing_output(self):
destination = self.root / 'original.pptx'
destination.write_bytes(b'original')
self.record['materials'] = [self.material('invoice', self.root / 'missing.pdf')]
with self.assertRaisesRegex(ValueError, '申请 #2'):
dispatch(dict(operation='approved-ppt', state=dict(approvedRecords=[self.record]), destination=str(destination)))
self.assertEqual(destination.read_bytes(), b'original')
self.assertEqual(list(self.root.glob('.*.pptx')), [])
def test_rejects_empty_and_excessive_batches(self):
for records in ([], [self.record] * 101):
with self.assertRaises(ValueError):
export_ppt(dict(approvedRecords=records), self.root / 'invalid.pptx', False)
def test_engine_process_protocol(self):
binary = os.environ.get('RECEIPT_ENGINE_BINARY')
command = [binary] if binary else [sys.executable, str(Path(__file__).resolve().parents[1] / 'engine.py')]
self.record['materials'] = [self.material('invoice'), self.material('payment')]
destination = self.root / 'process.pptx'
request = dict(operation='approved-ppt', state=dict(approvedRecords=[self.record]), destination=str(destination))
process = subprocess.run(command, input=json.dumps(request) + '\n', capture_output=True, text=True, timeout=60)
self.assertEqual(process.returncode, 0, process.stdout + process.stderr)
events = [json.loads(line) for line in process.stdout.splitlines()]
self.assertEqual(events[-1]['event'], 'result')
self.assertEqual(events[-1]['result']['destination'], str(destination))
self.assertEqual(len(Presentation(destination).slides), 3)
if __name__ == '__main__':
unittest.main()
+43
View File
@@ -71,6 +71,49 @@ struct AdminExpense: Codable, Identifiable {
let amountCents: Int64; let amountText: String; let note: String; let status: String; let state: String let amountCents: Int64; let amountText: String; let note: String; let status: String; let state: String
let date: String; let version: Int; let files: [String: AdminAttachmentList]; let events: [AdminEvent] let date: String; let version: Int; let files: [String: AdminAttachmentList]; let events: [AdminEvent]
} }
enum AdminPPTOrder: String, CaseIterable, Identifiable {
case group = "组别 → 人员 → 时间"
case person = "人员 → 组别 → 时间"
case date = "时间 → 组别 → 人员"
var id: String { rawValue }
func sorted(_ records: [AdminExpense]) -> [AdminExpense] {
records.sorted { left, right in
let leftGroup = [left.projectName, left.team, String(left.groupId)]
let rightGroup = [right.projectName, right.team, String(right.groupId)]
let leftPerson = [left.name, left.userId]
let rightPerson = [right.name, right.userId]
let leftKeys: [String]
let rightKeys: [String]
switch self {
case .group:
leftKeys = leftGroup + leftPerson + [left.date, left.id]
rightKeys = rightGroup + rightPerson + [right.date, right.id]
case .person:
leftKeys = leftPerson + leftGroup + [left.date, left.id]
rightKeys = rightPerson + rightGroup + [right.date, right.id]
case .date:
leftKeys = [left.date] + leftGroup + leftPerson + [left.id]
rightKeys = [right.date] + rightGroup + rightPerson + [right.id]
}
for (leftKey, rightKey) in zip(leftKeys, rightKeys) where leftKey != rightKey {
return leftKey.compare(rightKey, options: [.numeric], locale: Locale(identifier: "zh_CN")) == .orderedAscending
}
return false
}
}
}
enum AdminPPTSelection {
static func filter(_ records: [AdminExpense], groupID: Int64, userID: String, start: String?, end: String?) -> [AdminExpense] {
records.filter { record in
record.state == "approved" && (groupID == 0 || record.groupId == groupID) &&
(userID.isEmpty || record.userId == userID) &&
(start.map { record.date >= $0 } ?? true) && (end.map { record.date <= $0 } ?? true)
}
}
}
struct AdminExpenseMaterial: Codable, Identifiable, Equatable { struct AdminExpenseMaterial: Codable, Identifiable, Equatable {
let kind: String let kind: String
let name: String let name: String
+289
View File
@@ -0,0 +1,289 @@
import SwiftUI
import Combine
import UniformTypeIdentifiers
@MainActor final class AdminPPTExport: ObservableObject {
@Published var records: [AdminExpense] = []
@Published var busy = false
@Published var status = ""
@Published var progress = 0.0
@Published var error: String?
@Published var destination: URL?
private var task: Task<Void, Never>?
private let engine = EngineBridge()
func cancel() {
task?.cancel()
engine.cancel()
}
func load(_ model: AdminStore) {
guard !busy else { return }
busy = true; error = nil; progress = 0; destination = nil
let projectID = model.projectID
let groupID = model.groupID
let origin = model.origin
let token = model.session?.token
task = Task {
defer { busy = false }
do {
var all: [AdminExpense] = []
var before = "0"
var seen: Set<String> = []
while true {
try Task.checkCancellation()
status = "正在加载已批准申请(已检查 \(seen.count) 笔)…"
let page: [AdminExpense] = try await model.request("/api/admin/expenses?groupId=\(groupID)&before=\(before)")
guard model.origin == origin, model.session?.token == token else { throw CancellationError() }
guard page.allSatisfy({ !seen.contains($0.id) }) else {
throw AdminFailure(message: "分页数据异常,请刷新后重试")
}
seen.formUnion(page.map(\.id))
all += page.filter { $0.state == "approved" && (projectID == 0 || $0.projectId == projectID) }
if page.count < 100 { break }
guard seen.count < 10000, let last = page.last else {
throw AdminFailure(message: "当前范围超过 10,000 笔申请,请关闭弹窗并选择具体组别后重试")
}
before = last.id
}
try Task.checkCancellation()
records = all
status = "已加载当前范围内全部已批准申请,共 \(all.count)"
} catch is CancellationError {
status = "已取消加载,可重新加载"
} catch { records = []; self.error = error.localizedDescription }
}
}
func export(_ selected: [AdminExpense], model: AdminStore) {
guard !busy, !selected.isEmpty, selected.count <= 100 else { return }
busy = true; error = nil; destination = nil; progress = 0
let origin = model.origin
let token = model.session?.token
task = Task {
defer { busy = false }
var temporary: URL?
defer { if let temporary { try? FileManager.default.removeItem(at: temporary) } }
do {
let panel = NSSavePanel()
panel.nameFieldStringValue = "已批准报销贴票-\(selected.count)笔.pptx"
panel.allowedContentTypes = [UTType(filenameExtension: "pptx") ?? .data]
guard await panel.begin() == .OK, let output = panel.url else { return }
try Task.checkCancellation()
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("approved-ppt-" + UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
temporary = directory
var prepared: [[String: Any]] = []
var bytes = 0
for (index, record) in selected.enumerated() {
try Task.checkCancellation()
guard model.origin == origin, model.session?.token == token else { throw CancellationError() }
status = "正在核验并下载 \(index + 1)/\(selected.count) · \(record.name) · #\(record.id)"
let access: AdminAccess = try await model.request("/api/access")
guard access.groups.contains(where: { $0.id == record.groupId && $0.canManage }) else {
throw AdminFailure(message: "申请 #\(record.id) 所属组的管理权限已变化,请重新加载")
}
let detail: AdminExpense = try await model.request("/api/expenses/\(record.id)")
guard detail.state == "approved", detail.version == record.version,
detail.groupId == record.groupId, detail.userId == record.userId else {
throw AdminFailure(message: "申请 #\(record.id) 状态已变化,请重新加载后选择")
}
var materials: [[String: Any]] = []
let files = detail.files.keys.sorted().flatMap { detail.files[$0]?.files ?? [] }
for file in files {
try Task.checkCancellation()
guard file.size >= 0, file.size <= 10 * 1024 * 1024,
Int64(bytes) + file.size <= 200 * 1024 * 1024 else {
throw AdminFailure(message: "附件合计超过 200 MB 或单份超过 10 MB,请分批导出")
}
let data = try await model.fileData(file)
bytes += data.count
guard bytes <= 200 * 1024 * 1024 else { throw AdminFailure(message: "附件合计超过 200 MB,请分批导出") }
let pdf = file.mime == "application/pdf"
let path = directory.appendingPathComponent(UUID().uuidString + (pdf ? ".pdf" : ".png"))
if pdf {
try data.write(to: path, options: .atomic)
} else {
guard file.mime.hasPrefix("image/") else {
throw AdminFailure(message: "申请 #\(record.id) 的“\(file.name)”无法读取,请检查附件后重试")
}
let png = try await Task.detached(priority: .userInitiated) {
guard let bitmap = NSBitmapImageRep(data: data),
let converted = bitmap.representation(using: .png, properties: [:]) else {
throw AdminFailure(message: "图片“\(file.name)”无法读取,请检查附件后重试")
}
return converted
}.value
try Task.checkCancellation()
try png.write(to: path, options: .atomic)
}
materials.append(["path": path.path, "kind": file.kind, "name": file.name,
"kindName": model.uploadKinds.first { $0.kind == file.kind }?.name ?? "其他材料"])
}
prepared.append(["id": detail.id, "projectName": detail.projectName, "team": detail.team,
"name": detail.name, "date": detail.date, "type": detail.type,
"amountText": detail.amountText, "note": detail.note, "materials": materials])
progress = Double(index + 1) / Double(selected.count) * 0.75
}
try Task.checkCancellation()
status = "正在排版 PPT…"
let generated = directory.appendingPathComponent("approved.pptx")
let request = try JSONSerialization.data(withJSONObject: ["operation": "approved-ppt", "state": ["approvedRecords": prepared], "destination": generated.path])
_ = try await engine.run(request: request) { [weak self] progress, message in
Task { @MainActor in
self?.progress = 0.75 + progress * 0.24
self?.status = message
}
}
try Task.checkCancellation()
guard model.origin == origin, model.session?.token == token else { throw CancellationError() }
try Data(contentsOf: generated, options: .mappedIfSafe).write(to: output, options: .atomic)
destination = output; progress = 1; status = "已导出 \(selected.count) 笔已批准报销"
} catch is CancellationError {
status = "已取消导出,选择已保留"
} catch {
self.error = error.localizedDescription
status = "导出未完成,选择已保留;未写入不完整的 PPT"
}
}
}
}
struct AdminPPTExportSheet: View {
@ObservedObject var model: AdminStore
let initialSelection: Set<String>
@StateObject private var exporter = AdminPPTExport()
@Environment(\.dismiss) private var dismiss
@State private var selection: Set<String> = []
@State private var groupID: Int64 = 0
@State private var userID = ""
@State private var search = ""
@State private var order: AdminPPTOrder = .group
@State private var limitDates = false
@State private var startDate = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()
@State private var endDate = Date()
private func dateKey(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.calendar = Calendar(identifier: .gregorian)
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: date)
}
private var invalidDates: Bool { limitDates && dateKey(startDate) > dateKey(endDate) }
private var filtered: [AdminExpense] {
guard !invalidDates else { return [] }
let records = AdminPPTSelection.filter(exporter.records, groupID: groupID, userID: userID,
start: limitDates ? dateKey(startDate) : nil, end: limitDates ? dateKey(endDate) : nil)
return order.sorted(records.filter { AdminDisplayFilter.matches(search, fields: [$0.name, $0.type, $0.id, $0.userId]) })
}
private var selected: [AdminExpense] { order.sorted(exporter.records.filter { selection.contains($0.id) }) }
private var groupChoices: [AdminExpense] {
var seen: Set<Int64> = []
return AdminPPTOrder.group.sorted(exporter.records).filter { seen.insert($0.groupId).inserted }
}
private var people: [AdminExpense] {
var seen: Set<String> = []
return AdminPPTOrder.person.sorted(exporter.records.filter { groupID == 0 || $0.groupId == groupID })
.filter { seen.insert($0.userId).inserted }
}
private var scopeText: String {
if let group = model.selectedGroup { return group.label }
return model.access?.projects.first { $0.id == model.projectID }?.name ?? "全部授权项目 / 组别"
}
var body: some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
Label("导出已批准报销 PPT", systemImage: "doc.richtext").font(.title2.bold())
Spacer()
Button("关闭") { dismiss() }.disabled(exporter.busy)
}
Text("只导出已批准申请,逐笔保留归属信息。发票大图、付款凭证拼页;照片及其他材料自动贴入,多页 PDF 完整展开。")
.font(.callout).foregroundStyle(.secondary)
Text("导出范围:\(scopeText)").font(.callout.weight(.medium))
HStack {
Picker("组别", selection: $groupID) {
Text("当前范围全部组").tag(Int64(0))
ForEach(groupChoices) { Text("\($0.projectName) / \($0.team)").tag($0.groupId) }
}
Picker("人员", selection: $userID) {
Text("全部人员").tag("")
ForEach(people) { Text("\($0.name)(账户 \($0.userId)").tag($0.userId) }
}
}.disabled(exporter.busy)
HStack {
Toggle("按提交日期筛选", isOn: $limitDates)
if limitDates {
DatePicker("", selection: $startDate, displayedComponents: .date)
DatePicker("", selection: $endDate, displayedComponents: .date)
}
Spacer()
Picker("排序", selection: $order) {
ForEach(AdminPPTOrder.allCases) { Text($0.rawValue).tag($0) }
}.frame(width: 290)
}.disabled(exporter.busy)
if invalidDates { Text("开始日期不能晚于结束日期").foregroundStyle(.red) }
HStack {
TextField("搜索姓名、类型或申请编号", text: $search).textFieldStyle(.roundedBorder).frame(width: 235)
Button("全选筛选结果") { selection.formUnion(filtered.map(\.id)) }.disabled(filtered.isEmpty)
Button("取消当前筛选选择") { selection.subtract(filtered.map(\.id)) }.disabled(filtered.isEmpty)
Button("清空全部选择") { selection = [] }.disabled(selection.isEmpty)
Spacer()
Text("筛选结果 \(filtered.count) 笔 · 时间从早到晚").foregroundStyle(.secondary)
}.disabled(exporter.busy)
Table(filtered) {
TableColumn("选择") { record in
Toggle("选择申请 #\(record.id)", isOn: Binding(get: { selection.contains(record.id) }, set: {
if $0 { selection.insert(record.id) } else { selection.remove(record.id) }
})).labelsHidden()
}.width(45)
TableColumn("项目", value: \.projectName)
TableColumn("组别", value: \.team)
TableColumn("申请人", value: \.name)
TableColumn("类型", value: \.type)
TableColumn("金额") { Text("¥\($0.amountText)") }
TableColumn("提交日期", value: \.date)
TableColumn("编号", value: \.id).width(55)
}.frame(minHeight: 200).disabled(exporter.busy)
.overlay {
if filtered.isEmpty && !exporter.busy {
Text(exporter.records.isEmpty ? "当前范围暂无已批准申请" : "没有符合筛选条件的已批准申请")
.foregroundStyle(.secondary).allowsHitTesting(false)
}
}
Text("已选 \(selected.count) 笔 · 合计 ¥\(String(format: "%.2f", Double(selected.reduce(Int64(0)) { $0 + $1.amountCents }) / 100)) · 其中 \(selection.subtracting(Set(filtered.map(\.id))).count) 笔在当前筛选外")
.font(.callout.weight(.medium))
Text("切换筛选保留勾选;按所选排序合并为一个 PPT。每批最多 100 笔、原附件合计 200 MB。日期按提交日期,不是审批或发票日期。")
.font(.caption).foregroundStyle(.secondary)
if selected.count > 100 { Text("已超过 100 笔,请减少选择或分批导出").foregroundStyle(.red) }
if let error = exporter.error { Text(error).foregroundStyle(.red).textSelection(.enabled) }
HStack {
if exporter.busy {
ProgressView().controlSize(.small)
Text(exporter.status).lineLimit(1)
Spacer()
Button("取消") { exporter.cancel() }
} else {
Text(exporter.status).foregroundStyle(.secondary)
Spacer()
if let destination = exporter.destination {
Button("在 Finder 中显示") { NSWorkspace.shared.activateFileViewerSelecting([destination]) }
}
Button("重新加载") { exporter.load(model) }
Button("导出所选 \(selected.count)") { exporter.export(selected, model: model) }
.buttonStyle(.borderedProminent).disabled(selected.isEmpty || selected.count > 100 || invalidDates)
}
}
if exporter.busy && exporter.progress > 0 { ProgressView(value: exporter.progress) }
}.padding(24).frame(width: 1040, height: 700)
.interactiveDismissDisabled(exporter.busy)
.task { selection = initialSelection; exporter.load(model) }
.onDisappear { exporter.cancel() }
.onChange(of: groupID) { _, _ in userID = "" }
.onChange(of: exporter.records.map(\.id)) { _, ids in selection.formIntersection(Set(ids)) }
.onChange(of: exporter.busy) { _, busy in
if !busy { selection.formIntersection(Set(exporter.records.map(\.id))) }
}
}
}
+6
View File
@@ -19,6 +19,7 @@ struct AdminView: View {
@State private var selectedGroups: Set<Int64> = [] @State private var selectedGroups: Set<Int64> = []
@State private var selectedExpenses: Set<String> = [] @State private var selectedExpenses: Set<String> = []
@State private var showDateExport = false @State private var showDateExport = false
@State private var showPPTExport = false
@State private var exportStartDate = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date() @State private var exportStartDate = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()
@State private var exportEndDate = Date() @State private var exportEndDate = Date()
@State private var hours = 168 @State private var hours = 168
@@ -102,6 +103,9 @@ struct AdminView: View {
.sheet(isPresented: $showDateExport) { .sheet(isPresented: $showDateExport) {
dateExport dateExport
} }
.sheet(isPresented: $showPPTExport) {
AdminPPTExportSheet(model: model, initialSelection: selectedExpenses)
}
.sheet(item: $renameTarget) { group in .sheet(item: $renameTarget) { group in
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
Text("重命名组别").font(.title3.bold()) Text("重命名组别").font(.title3.bold())
@@ -296,6 +300,8 @@ struct AdminView: View {
Text("已退回").tag("returned") Text("已退回").tag("returned")
}.frame(width: 180) }.frame(width: 180)
Spacer() Spacer()
Button { showPPTExport = true } label: { Label("导出 PPT", systemImage: "doc.richtext") }
.help("筛选已批准的申请,按组别、人员和提交日期批量导出贴票 PPT")
Button { showDateExport = true } label: { Label("按日期导出", systemImage: "calendar.badge.arrow.down") } Button { showDateExport = true } label: { Label("按日期导出", systemImage: "calendar.badge.arrow.down") }
} }
ZStack { ZStack {
+27
View File
@@ -0,0 +1,27 @@
import Foundation
@main
struct AdminPPTSelectionTests {
static func record(_ id: String, user: String = "1", name: String = "张三", group: Int64 = 1,
date: String = "2026-09-13", state: String = "approved") -> AdminExpense {
AdminExpense(id: id, groupId: group, projectId: 1, userId: user, name: name, team: "\(group)",
projectName: "项目", type: "餐饮费用", amountCents: 1234, amountText: "12.34", note: "",
status: "已批准", state: state, date: date, version: 1, files: [:], events: [])
}
static func main() {
let records = [record("10"), record("2"), record("3", user: "2", group: 2, date: "2026-09-12"),
record("4", state: "pending"), record("5", state: "returned")]
let approved = AdminPPTSelection.filter(records, groupID: 0, userID: "", start: nil, end: nil)
precondition(approved.count == 3)
precondition(AdminPPTSelection.filter(records, groupID: 1, userID: "", start: nil, end: nil).count == 2)
precondition(AdminPPTSelection.filter(records, groupID: 0, userID: "2", start: nil, end: nil).map(\.id) == ["3"])
precondition(AdminPPTSelection.filter(records, groupID: 0, userID: "", start: "2026-09-13", end: "2026-09-13").count == 2)
precondition(AdminPPTSelection.filter(records, groupID: 0, userID: "", start: "2026-09-14", end: "2026-09-13").isEmpty)
precondition(AdminPPTOrder.group.sorted(approved).map(\.id) == ["2", "10", "3"])
precondition(AdminPPTOrder.date.sorted(approved).map(\.id) == ["3", "2", "10"])
let sameName = [record("7", user: "2"), record("6", user: "1", group: 2)]
precondition(AdminPPTOrder.person.sorted(sameName).map(\.id) == ["6", "7"])
print("PPT selection, approval filtering, date bounds and ordering tests passed")
}
}