Skip to content

Commit 8dd6eb6

Browse files
committed
feat: 身份证打印支持裁剪旋转和 A5 版面
- ImageCropModal 裁剪弹窗新增左旋/右旋按钮,复用 cropperjs rotate - 身份证模式新增 A4/A5 版面切换,选 A5 时自动联动纸张大小 - 后端 composeIdCard 支持 A5 尺寸(148×210mm)PDF 生成
1 parent e010591 commit 8dd6eb6

4 files changed

Lines changed: 78 additions & 6 deletions

File tree

cmd/server/compose_handler.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,11 @@ func composeHandler(w http.ResponseWriter, r *http.Request) {
3939
case "invoice":
4040
outPath, cleanup, err = composeInvoice2Up(ctx, headers)
4141
case "id_card":
42-
outPath, cleanup, err = composeIdCard(ctx, headers)
42+
paper := r.FormValue("paper")
43+
if paper == "" {
44+
paper = "A4"
45+
}
46+
outPath, cleanup, err = composeIdCard(ctx, headers, paper)
4347
default:
4448
writeJSONError(w, http.StatusBadRequest, "unsupported mode: "+mode)
4549
return

cmd/server/pdf_compose.go

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ const (
100100
idCardGapMM = 30.0
101101
)
102102

103-
func composeIdCard(ctx context.Context, fileHeaders []*multipart.FileHeader) (string, func(), error) {
103+
func composeIdCard(ctx context.Context, fileHeaders []*multipart.FileHeader, paper string) (string, func(), error) {
104104
if len(fileHeaders) != 2 {
105105
return "", nil, errors.New("id card mode requires exactly 2 files (front and back)")
106106
}
@@ -131,14 +131,50 @@ func composeIdCard(ctx context.Context, fileHeaders []*multipart.FileHeader) (st
131131
}
132132
}
133133

134+
var pageW, pageH float64
135+
if paper == "A5" {
136+
pageW, pageH = 148.0, 210.0
137+
pdf := gofpdf.NewCustom(&gofpdf.InitType{
138+
OrientationStr: "P",
139+
UnitStr: "mm",
140+
Size: gofpdf.SizeType{Wd: pageW, Ht: pageH},
141+
})
142+
pdf.SetMargins(0, 0, 0)
143+
pdf.SetAutoPageBreak(false, 0)
144+
pdf.AddPage()
145+
146+
imp := gofpdi.NewImporter()
147+
148+
halfH := pageH / 2
149+
cardX := (pageW - idCardWidthMM) / 2
150+
card1Y := (halfH - idCardHeightMM) / 2
151+
card2Y := halfH + (halfH-idCardHeightMM)/2
152+
153+
if err := placePageInSlot(pdf, imp, &pages[0], cardX, card1Y, idCardWidthMM, idCardHeightMM); err != nil {
154+
cleanup()
155+
return "", nil, fmt.Errorf("front: %w", err)
156+
}
157+
if err := placePageInSlot(pdf, imp, &pages[1], cardX, card2Y, idCardWidthMM, idCardHeightMM); err != nil {
158+
cleanup()
159+
return "", nil, fmt.Errorf("back: %w", err)
160+
}
161+
162+
outPath := filepath.Join(tmpDir, "idcard_composed.pdf")
163+
if err := pdf.OutputFileAndClose(outPath); err != nil {
164+
cleanup()
165+
return "", nil, err
166+
}
167+
return outPath, cleanup, nil
168+
}
169+
170+
pageW, pageH = 210.0, 297.0
134171
pdf := gofpdf.New("P", "mm", "A4", "")
135172
pdf.SetMargins(0, 0, 0)
136173
pdf.SetAutoPageBreak(false, 0)
137174
pdf.AddPage()
138175

139176
imp := gofpdi.NewImporter()
140177

141-
pageW, pageH := 210.0, 297.0
142178
halfH := pageH / 2
143179
cardX := (pageW - idCardWidthMM) / 2
144180
card1Y := (halfH - idCardHeightMM) / 2

frontend/src/components/print/ImageCropModal.vue

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,15 @@
99
<div class="relative bg-elevated rounded-lg overflow-hidden" style="max-height: 60vh;">
1010
<img ref="imgRef" :src="imageUrl" class="max-w-full block" />
1111
</div>
12-
<div class="flex justify-end gap-2">
13-
<UButton variant="ghost" @click="cancel">取消</UButton>
14-
<UButton color="primary" icon="i-lucide-check" @click="confirm">确认裁剪</UButton>
12+
<div class="flex items-center justify-between">
13+
<div class="flex gap-1">
14+
<UButton variant="outline" size="sm" icon="i-lucide-rotate-ccw" @click="rotateLeft">左旋</UButton>
15+
<UButton variant="outline" size="sm" icon="i-lucide-rotate-cw" @click="rotateRight">右旋</UButton>
16+
</div>
17+
<div class="flex gap-2">
18+
<UButton variant="ghost" @click="cancel">取消</UButton>
19+
<UButton color="primary" icon="i-lucide-check" @click="confirm">确认裁剪</UButton>
20+
</div>
1521
</div>
1622
</div>
1723
</template>
@@ -159,6 +165,9 @@ function confirm() {
159165
}, 'image/jpeg', 0.92)
160166
}
161167
168+
function rotateLeft() { if (cropper) cropper.rotate(-90) }
169+
function rotateRight() { if (cropper) cropper.rotate(90) }
170+
162171
function cancel() { isOpen.value = false }
163172
164173
onBeforeUnmount(() => destroyCropper())

frontend/src/views/PrintView.vue

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,20 @@
133133
@update:front="onIdCardFront"
134134
@update:back="onIdCardBack"
135135
/>
136+
<div v-if="printMode === 'id_card'" class="flex items-center gap-3">
137+
<span class="text-sm text-muted shrink-0">版面</span>
138+
<div class="flex rounded-lg border border-muted overflow-hidden">
139+
<label
140+
v-for="p in ['A4', 'A5']"
141+
:key="p"
142+
class="px-3 py-1 cursor-pointer text-sm transition"
143+
:class="idCardPaper === p ? 'bg-primary text-white font-medium' : 'hover:bg-elevated'"
144+
>
145+
<input type="radio" :value="p" :checked="idCardPaper === p" class="sr-only" @change="setIdCardPaper(p)" />
146+
{{ p }}
147+
</label>
148+
</div>
149+
</div>
136150
<UButton
137151
v-if="printMode === 'id_card' && idCardFront && idCardBack"
138152
variant="outline"
@@ -284,6 +298,7 @@ const idCardFront = ref(null)
284298
const idCardBack = ref(null)
285299
const idCardFrontPreview = ref('')
286300
const idCardBackPreview = ref('')
301+
const idCardPaper = ref('A4')
287302
const composing = ref(false)
288303
289304
// ─── 状态 ─────────────────────────────────────────────────
@@ -908,12 +923,18 @@ function switchMode(mode) {
908923
} else if (mode === 'id_card') {
909924
isColor.value = true
910925
printScaling.value = 'none'
926+
paperSize.value = 'A4'
911927
} else {
912928
isColor.value = true
913929
printScaling.value = 'fit'
914930
}
915931
}
916932
933+
function setIdCardPaper(p) {
934+
idCardPaper.value = p
935+
paperSize.value = p
936+
}
937+
917938
function clearModeState() {
918939
invoiceFiles.value = []
919940
if (idCardFrontPreview.value) { try { URL.revokeObjectURL(idCardFrontPreview.value) } catch (_) {} }
@@ -922,6 +943,7 @@ function clearModeState() {
922943
idCardBack.value = null
923944
idCardFrontPreview.value = ''
924945
idCardBackPreview.value = ''
946+
idCardPaper.value = 'A4'
925947
composing.value = false
926948
}
927949
@@ -984,6 +1006,7 @@ async function composeAndPreview() {
9841006
}
9851007
} else if (printMode.value === 'id_card') {
9861008
fd.append('mode', 'id_card')
1009+
fd.append('paper', idCardPaper.value)
9871010
fd.append('files', idCardFront.value, idCardFront.value.name)
9881011
fd.append('files', idCardBack.value, idCardBack.value.name)
9891012
}

0 commit comments

Comments
 (0)