增加票通票样功能

This commit is contained in:
BBIT-Kai
2026-05-19 17:43:39 +08:00
parent 74fdddd113
commit cdcfaa192c
14 changed files with 376 additions and 466 deletions
+1
View File
@@ -1,2 +1,3 @@
web/node_modules/
web/dist/
log/
-452
View File
File diff suppressed because one or more lines are too long
@@ -10,11 +10,13 @@ import com.bbit.ticket.database.piaotong.HistoryInvoiceVoucherTable
import com.bbit.ticket.entity.common.PageResult
import com.bbit.ticket.entity.request.AskInvoiceRequest
import com.bbit.ticket.entity.response.GetInvoiceInfoResponse
import com.bbit.ticket.entity.response.InvoiceDownloadUrlResponse
import com.bbit.ticket.entity.response.InvoiceDetailGoods
import com.bbit.ticket.entity.response.InvoiceDetailOrder
import com.bbit.ticket.entity.response.InvoiceDetailResponse
import com.bbit.ticket.entity.response.InvoiceDetailVoucher
import com.bbit.ticket.entity.response.InvoiceHistoryItem
import com.bbit.ticket.utils.Base64TextUtil
import com.bbit.ticket.utils.formatDateTime
import org.jetbrains.exposed.v1.core.Column
import org.jetbrains.exposed.v1.core.Op
@@ -332,6 +334,7 @@ object BlueInvoiceDao {
it.setIfNotNull(HistoryInvoiceBasicTable.definedData, req.definedData)
// 文件
it.setIfNotNull(HistoryInvoiceBasicTable.invoiceLayoutFileType, req.invoiceLayoutFileType)
it.setIfNotNull(HistoryInvoiceBasicTable.downloadUrl, Base64TextUtil.decodeToText(req.downloadUrl))
// 删除标记
it[HistoryInvoiceBasicTable.invDeletedFlag] = req.invDeletedFlag ?: "0"
}
@@ -348,6 +351,18 @@ object BlueInvoiceDao {
?: throw com.bbit.ticket.entity.common.BizException("NOT_FOUND", "发票记录不存在用户信息")
}
fun invoiceDownloadUrl(userId: Uuid, invoiceReqSerialNo: String): InvoiceDownloadUrlResponse? {
val row = HistoryInvoiceBasicTable.selectAll()
.where {
(HistoryInvoiceBasicTable.userId eq userId) and
(HistoryInvoiceBasicTable.invoiceReqSerialNo eq invoiceReqSerialNo) and
HistoryInvoiceBasicTable.deletedAt.isNull()
}
.singleOrNull() ?: return null
return InvoiceDownloadUrlResponse(row[HistoryInvoiceBasicTable.downloadUrl])
}
/**
* 查询发票完整详情(含商品明细、差额征税凭证、关联单据)
*/
@@ -381,7 +396,7 @@ object BlueInvoiceDao {
invoiceAmount = row[HistoryInvoiceGoodsTable.itemAmount].toPlainString(),
taxRateValue = row[HistoryInvoiceGoodsTable.taxRate].toPlainString(),
taxRateAmount = row[HistoryInvoiceGoodsTable.taxRateAmount].toPlainString(),
includeTaxFlag = false,
includeTaxFlag = row[HistoryInvoiceGoodsTable.includeTaxFlag] == "1",
zeroTaxFlag = row[HistoryInvoiceGoodsTable.zeroTaxFlag],
preferentialPolicyFlag = row[HistoryInvoiceGoodsTable.preferentialPolicyFlag],
vatSpecialManage = row[HistoryInvoiceGoodsTable.vatSpecialManage],
@@ -5,6 +5,7 @@ package com.bbit.ticket.dao.piaotong
import com.bbit.ticket.database.piaotong.HistoryInvoiceBasicTable
import com.bbit.ticket.database.piaotong.HistoryInvoiceRedTable
import com.bbit.ticket.entity.request.QuickRedInvoiceRequest
import com.bbit.ticket.entity.response.InvoiceDownloadUrlResponse
import com.bbit.ticket.entity.response.RedInvoiceInfoResponse
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
@@ -46,6 +47,7 @@ object RedInvoiceDao {
it[HistoryInvoiceBasicTable.invoiceKind] = req.invoiceKind
?: blueRow?.get(HistoryInvoiceBasicTable.invoiceKind) ?: "82"
it[HistoryInvoiceBasicTable.invoiceType] = "2" // 红票
it[HistoryInvoiceBasicTable.redFlag] = "REDING"
// ---- 红冲关联 ----
it[HistoryInvoiceBasicTable.blueInvoiceCode] = blueRow?.get(HistoryInvoiceBasicTable.invoiceCode)
@@ -115,4 +117,17 @@ object RedInvoiceDao {
takerEmail = row[HistoryInvoiceRedTable.takerEmail],
)
}
fun invoiceDownloadUrl(userId: Uuid, invoiceReqSerialNo: String): InvoiceDownloadUrlResponse? {
val row = HistoryInvoiceBasicTable.selectAll()
.where {
(HistoryInvoiceBasicTable.userId eq userId) and
(HistoryInvoiceBasicTable.invoiceReqSerialNo eq invoiceReqSerialNo) and
(HistoryInvoiceBasicTable.invoiceType eq "2") and
HistoryInvoiceBasicTable.deletedAt.isNull()
}
.singleOrNull() ?: return null
return InvoiceDownloadUrlResponse(row[HistoryInvoiceBasicTable.downloadUrl])
}
}
@@ -423,6 +423,7 @@ object HistoryInvoiceBasicTable : Table("history_invoice_basic") {
* 1:已删除
*/
val invDeletedFlag = varchar("inv_deleted_flag", 1)
val downloadUrl = text("download_url").nullable()
// ----------------------------------------------------------------
// 时间字段
@@ -130,7 +130,11 @@ object HistoryInvoiceGoodsTable : Table("history_invoice_goods") {
* 税额
*/
val taxRateAmount = decimal("tax_rate_amount", 18, 2)
/**
* 税额
*/
val includeTaxFlag = varchar("include_tax_flag", 1)
.nullable()
/**
* 扣除额
*
@@ -0,0 +1,8 @@
package com.bbit.ticket.entity.response
import kotlinx.serialization.Serializable
@Serializable
data class InvoiceDownloadUrlResponse(
val downloadUrl: String? = null
)
@@ -12,8 +12,12 @@ import com.bbit.ticket.entity.request.RedCreateRequest
import com.bbit.ticket.service.piaotong.PTBlueService
import com.bbit.ticket.service.piaotong.PTRedService
import com.bbit.ticket.utils.requireCurrentUser
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.server.request.receive
import io.ktor.server.response.header
import io.ktor.server.response.respond
import io.ktor.server.response.respondBytes
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.post
@@ -93,6 +97,86 @@ fun Route.registerPTiInvoiceRoutes() {
call.respond(fail(code = "-1", message = e.message ?: "查询发票详情失败"))
}
}
get("/invoiceDownloadUrl") {
try {
val currentUser = call.requireCurrentUser()
val invoiceReqSerialNo = call.request.queryParameters["invoiceReqSerialNo"]
if (invoiceReqSerialNo.isNullOrBlank()) {
call.respond(fail(code = "-1", message = "璇蜂紶鍏ュ彂绁ㄨ姹傛祦姘村彿"))
return@get
}
val response = PTBlueService.getInvoiceDownloadUrl(currentUser.id, invoiceReqSerialNo)
if (response == null) {
call.respond(fail(code = "-1", message = "鏈壘鍒拌鍙戠エ璁板綍"))
return@get
}
call.respond(ok(response))
} catch (e: Exception) {
call.respond(fail(code = "-1", message = e.message ?: "鏌ヨ鍙戠エ涓嬭浇鍦板潃澶辫触"))
}
}
get("/invoicePreview") {
try {
val currentUser = call.requireCurrentUser()
val invoiceReqSerialNo = call.request.queryParameters["invoiceReqSerialNo"]
if (invoiceReqSerialNo.isNullOrBlank()) {
call.respond(fail(code = "-1", message = "璇蜂紶鍏ュ彂绁ㄨ姹傛祦姘村彿"))
return@get
}
val bytes = PTBlueService.getInvoicePreview(currentUser.id, invoiceReqSerialNo)
if (bytes == null) {
call.respond(fail(code = "-1", message = "鏈壘鍒扮エ鏍峰湴鍧€"))
return@get
}
call.response.header(
HttpHeaders.ContentDisposition,
"inline; filename=\"${invoiceReqSerialNo}.pdf\""
)
call.respondBytes(bytes, ContentType.Application.Pdf)
} catch (e: Exception) {
call.respond(fail(code = "-1", message = e.message ?: "棰勮绁ㄦ牱澶辫触"))
}
}
get("/redInvoiceDownloadUrl") {
try {
val currentUser = call.requireCurrentUser()
val invoiceReqSerialNo = call.request.queryParameters["invoiceReqSerialNo"]
if (invoiceReqSerialNo.isNullOrBlank()) {
call.respond(fail(code = "-1", message = "请传入发票请求流水号"))
return@get
}
val response = PTRedService.getRedInvoiceDownloadUrl(currentUser.id, invoiceReqSerialNo)
if (response == null) {
call.respond(fail(code = "-1", message = "未找到该红票记录"))
return@get
}
call.respond(ok(response))
} catch (e: Exception) {
call.respond(fail(code = "-1", message = e.message ?: "查询红票下载地址失败"))
}
}
get("/redInvoicePreview") {
try {
val currentUser = call.requireCurrentUser()
val invoiceReqSerialNo = call.request.queryParameters["invoiceReqSerialNo"]
if (invoiceReqSerialNo.isNullOrBlank()) {
call.respond(fail(code = "-1", message = "请传入发票请求流水号"))
return@get
}
val bytes = PTRedService.getRedInvoicePreview(currentUser.id, invoiceReqSerialNo)
if (bytes == null) {
call.respond(fail(code = "-1", message = "未找到票样地址"))
return@get
}
call.response.header(
HttpHeaders.ContentDisposition,
"inline; filename=\"${invoiceReqSerialNo}.pdf\""
)
call.respondBytes(bytes, ContentType.Application.Pdf)
} catch (e: Exception) {
call.respond(fail(code = "-1", message = e.message ?: "预览红票票样失败"))
}
}
get("/queryInvoice") {
try {
val currentUser = call.requireCurrentUser()
@@ -7,10 +7,13 @@ import com.bbit.ticket.entity.common.PageResult
import com.bbit.ticket.entity.request.AskInvoiceRequest
import com.bbit.ticket.entity.request.QueryInvoiceRequest
import com.bbit.ticket.entity.response.GetInvoiceInfoResponse
import com.bbit.ticket.entity.response.InvoiceDownloadUrlResponse
import com.bbit.ticket.entity.response.QueryInvoiceResult
import com.bbit.ticket.entity.response.InvoiceCreateResponse
import com.bbit.ticket.entity.response.InvoiceDetailResponse
import com.bbit.ticket.entity.response.InvoiceHistoryItem
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsBytes
import com.bbit.ticket.utils.plugins.dbQuery
import com.bbit.ticket.utils.net.PTClient
import kotlin.uuid.Uuid
@@ -70,6 +73,16 @@ object PTBlueService {
suspend fun getInvoiceDetail(userId: Uuid, invoiceReqSerialNo: String): InvoiceDetailResponse? =
dbQuery { BlueInvoiceDao.invoiceDetail(userId, invoiceReqSerialNo) }
suspend fun getInvoiceDownloadUrl(userId: Uuid, invoiceReqSerialNo: String): InvoiceDownloadUrlResponse? =
dbQuery { BlueInvoiceDao.invoiceDownloadUrl(userId, invoiceReqSerialNo) }
suspend fun getInvoicePreview(userId: Uuid, invoiceReqSerialNo: String): ByteArray? {
val downloadUrl = getInvoiceDownloadUrl(userId, invoiceReqSerialNo)?.downloadUrl
?.takeIf { it.isNotBlank() }
?: return null
return PTClient.client.get(downloadUrl).bodyAsBytes()
}
/**
* 查询并更新发票状态(复用 syncInvoiceFromPT
*/
@@ -6,11 +6,14 @@ import com.bbit.ticket.dao.piaotong.HistoryDao
import com.bbit.ticket.dao.piaotong.RedInvoiceDao
import com.bbit.ticket.entity.request.QuickRedInvoiceRequest
import com.bbit.ticket.entity.request.RedCreateRequest
import com.bbit.ticket.entity.response.InvoiceDownloadUrlResponse
import com.bbit.ticket.entity.response.QuickRedInvoiceResponse
import com.bbit.ticket.entity.response.RedInvoiceInfoResponse
import com.bbit.ticket.utils.CurrentUser
import com.bbit.ticket.utils.plugins.dbQuery
import com.bbit.ticket.utils.net.PTClient
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsBytes
import kotlin.uuid.Uuid
/**
@@ -59,4 +62,13 @@ object PTRedService {
suspend fun getRedInvoiceInfo(userId: Uuid, invoiceReqSerialNo: String): RedInvoiceInfoResponse? =
dbQuery { RedInvoiceDao.findRedInfoBySerialNo(userId, invoiceReqSerialNo) }
suspend fun getRedInvoiceDownloadUrl(userId: Uuid, invoiceReqSerialNo: String): InvoiceDownloadUrlResponse? =
dbQuery { RedInvoiceDao.invoiceDownloadUrl(userId, invoiceReqSerialNo) }
suspend fun getRedInvoicePreview(userId: Uuid, invoiceReqSerialNo: String): ByteArray? {
val downloadUrl = getRedInvoiceDownloadUrl(userId, invoiceReqSerialNo)?.downloadUrl
?.takeIf { it.isNotBlank() }
?: return null
return PTClient.client.get(downloadUrl).bodyAsBytes()
}
}
@@ -0,0 +1,15 @@
package com.bbit.ticket.utils
import java.nio.charset.StandardCharsets
import java.util.Base64
object Base64TextUtil {
fun decodeToText(value: String?): String? {
val text = value?.trim() ?: return null
if (text.isEmpty()) return text
return runCatching {
String(Base64.getDecoder().decode(text), StandardCharsets.UTF_8)
}.getOrElse { text }
}
}
@@ -2,7 +2,7 @@ package com.bbit.ticket.utils.bootstrap
object Global {
val isDev = false
val isDev = true
// 请求基础地址
var baseUrl: String
+22
View File
@@ -705,6 +705,28 @@ export function invoiceDetailApi(invoiceReqSerialNo: string): Promise<InvoiceDet
return http.get('/pt/invoiceDetail', { params: { invoiceReqSerialNo } })
}
export function invoiceDownloadUrlApi(invoiceReqSerialNo: string): Promise<{ downloadUrl?: string }> {
return http.get('/pt/invoiceDownloadUrl', { params: { invoiceReqSerialNo } })
}
export function invoicePreviewBlobApi(invoiceReqSerialNo: string): Promise<Blob> {
return http.get('/pt/invoicePreview', {
params: { invoiceReqSerialNo },
responseType: 'blob'
})
}
export function redInvoiceDownloadUrlApi(invoiceReqSerialNo: string): Promise<{ downloadUrl?: string }> {
return http.get('/pt/redInvoiceDownloadUrl', { params: { invoiceReqSerialNo } })
}
export function redInvoicePreviewBlobApi(invoiceReqSerialNo: string): Promise<Blob> {
return http.get('/pt/redInvoicePreview', {
params: { invoiceReqSerialNo },
responseType: 'blob'
})
}
/**
* 查询并刷新发票状态
*/
@@ -361,6 +361,53 @@
</template>
</n-modal>
<n-modal
v-model:show="showSamplePreview"
preset="card"
title="查看票样"
:style="{ width: '980px', maxWidth: '94vw' }"
content-style="padding: 0"
>
<div class="sample-preview">
<div class="sample-toolbar">
<div class="sample-title">{{ sampleSerialNo }}</div>
<div class="sample-actions">
<n-button size="small" secondary @click="zoomOutSample">
<template #icon><ZoomOut :size="14" /></template>
缩小
</n-button>
<span class="sample-zoom">{{ sampleZoom }}%</span>
<n-button size="small" secondary @click="zoomInSample">
<template #icon><ZoomIn :size="14" /></template>
放大
</n-button>
<n-button
size="small"
type="primary"
tag="a"
:href="sampleBlobUrl || undefined"
:download="`${sampleSerialNo || 'invoice'}.pdf`"
:disabled="!sampleBlobUrl"
>
<template #icon><Download :size="14" /></template>
下载
</n-button>
</div>
</div>
<n-spin :show="sampleLoading">
<div class="sample-frame-wrap">
<iframe
v-if="sampleBlobUrl"
class="sample-frame"
:src="sampleBlobUrl"
:style="{ transform: `scale(${sampleZoom / 100})` }"
/>
<n-empty v-else description="暂无票样地址" />
</div>
</n-spin>
</div>
</n-modal>
<n-modal
v-model:show="showRedForm"
preset="card"
@@ -410,6 +457,7 @@ import type { Component } from 'vue'
import {
NButton,
NDataTable,
NEmpty,
NForm,
NFormItem,
NInput,
@@ -426,16 +474,24 @@ import {
Clock,
XCircle,
FileSpreadsheet,
FileSearch,
ZoomIn,
ZoomOut,
Download,
RotateCcw
} from 'lucide-vue-next'
import {
invoiceDownloadUrlApi,
invoicePreviewBlobApi,
invoiceDetailApi,
invoiceHistoryApi,
invoiceKindMap,
invoiceStatusMap,
queryInvoiceApi,
redInvoiceCreateApi,
redInvoiceDownloadUrlApi,
redInvoiceInfoApi,
redInvoicePreviewBlobApi,
redReasonMap
} from '@/api/piaotong'
import type {
@@ -446,7 +502,7 @@ import type {
RedCreateRequest,
RedInvoiceInfo
} from '@/api/piaotong'
import type { DataTableColumn } from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
const invoiceTypeMap: Record<string, string> = {
BLUE: '蓝票',
@@ -583,7 +639,7 @@ const pagination = reactive({
showSizePicker: true,
pageSizes: [10, 20, 50, 100],
pageSlot: 7,
prefix: ({ itemCount }: { itemCount: number }) => `${itemCount}`
prefix: ({ itemCount }: { itemCount?: number }) => `${itemCount ?? 0}`
})
async function fetchData() {
@@ -613,12 +669,22 @@ function handlePageSizeChange(pageSize: number) {
}
const refreshingSet = reactive(new Set<string>())
const showSamplePreview = ref(false)
const sampleLoading = ref(false)
const sampleUrl = ref('')
const sampleBlobUrl = ref('')
const sampleSerialNo = ref('')
const sampleZoom = ref(100)
function getRowActions(row: InvoiceHistoryItem) {
const actions: Array<{ label: string; icon?: Component; onClick: () => void }> = []
actions.push({ label: '详情', icon: Eye, onClick: () => showDetailInfo(row) })
actions.push({ label: '刷新', icon: RefreshCw, onClick: () => refreshStatus(row) })
if ((activeTab.value === 'BLUE' || activeTab.value === 'RED') && row.status === 'SUCCESS') {
actions.push({ label: '查看票样', icon: FileSearch, onClick: () => openSamplePreview(row) })
}
if (
activeTab.value === 'BLUE' &&
row.status === 'SUCCESS' &&
@@ -630,7 +696,47 @@ function getRowActions(row: InvoiceHistoryItem) {
return actions
}
const columns = computed<DataTableColumn[]>(() => [
async function openSamplePreview(item: InvoiceHistoryItem) {
showSamplePreview.value = true
sampleLoading.value = true
if (sampleBlobUrl.value) {
URL.revokeObjectURL(sampleBlobUrl.value)
}
sampleUrl.value = ''
sampleBlobUrl.value = ''
sampleSerialNo.value = item.invoiceReqSerialNo
sampleZoom.value = 100
try {
const isRedInvoice = activeTab.value === 'RED' || item.invoiceType === 'RED'
const res = isRedInvoice
? await redInvoiceDownloadUrlApi(item.invoiceReqSerialNo)
: await invoiceDownloadUrlApi(item.invoiceReqSerialNo)
sampleUrl.value = res.downloadUrl || ''
if (!sampleUrl.value) {
message.warning('暂无票样地址')
return
}
const blob = isRedInvoice
? await redInvoicePreviewBlobApi(item.invoiceReqSerialNo)
: await invoicePreviewBlobApi(item.invoiceReqSerialNo)
sampleBlobUrl.value = URL.createObjectURL(new Blob([blob], { type: 'application/pdf' }))
} catch {
message.error('预览票样失败')
} finally {
sampleLoading.value = false
}
}
function zoomInSample() {
sampleZoom.value = Math.min(200, sampleZoom.value + 10)
}
function zoomOutSample() {
sampleZoom.value = Math.max(50, sampleZoom.value - 10)
}
const columns = computed<DataTableColumns<InvoiceHistoryItem>>(() => {
const tableColumns: DataTableColumns<InvoiceHistoryItem> = [
{
title: '流水号',
key: 'invoiceReqSerialNo',
@@ -659,7 +765,17 @@ const columns = computed<DataTableColumn[]>(() => [
key: 'redFlag',
width: 110,
render: (row: InvoiceHistoryItem) => {
if (!row.redFlag || row.redFlag === 'NOT_RED') {
const redFlag =
row.redFlag ||
(row.invoiceType === 'RED'
? {
SUCCESS: 'ALREADY_RED',
PROCESSING: 'REDING',
PENDING: 'REDING',
FAILED: 'RED_FAIL'
}[row.status]
: undefined)
if (!redFlag || redFlag === 'NOT_RED') {
return h(NTag, { size: 'small', round: true }, () => '未冲红')
}
const typeMap: Record<string, 'error' | 'warning' | 'default'> = {
@@ -670,8 +786,8 @@ const columns = computed<DataTableColumn[]>(() => [
}
return h(
NTag,
{ size: 'small', round: true, type: typeMap[row.redFlag] || 'default' },
() => redFlagMap[row.redFlag] || row.redFlag
{ size: 'small', round: true, type: typeMap[redFlag] || 'default' },
() => redFlagMap[redFlag] || redFlag
)
}
},
@@ -713,6 +829,7 @@ const columns = computed<DataTableColumn[]>(() => [
{ style: 'display:flex;gap:6px;align-items:center;flex-wrap:wrap' },
actions.map((btn) => {
const isLoading = btn.label === '刷新' && refreshingSet.has(row.invoiceReqSerialNo)
const Icon = btn.icon
return h(
NButton,
{
@@ -723,14 +840,19 @@ const columns = computed<DataTableColumn[]>(() => [
},
{
default: () => btn.label,
...(btn.icon && !isLoading ? { icon: () => h(btn.icon, { size: 13 }) } : {})
...(Icon && !isLoading ? { icon: () => h(Icon, { size: 13 }) } : {})
}
)
})
)
}
}
])
]
return activeTab.value === 'RED'
? tableColumns.filter((column) => !('key' in column) || column.key !== 'redFlag')
: tableColumns
})
const showDetail = ref(false)
const detailLoading = ref(false)
@@ -830,7 +952,7 @@ async function handleRedSubmit() {
}
}
const goodsColumns: DataTableColumn[] = [
const goodsColumns: DataTableColumns<InvoiceDetailGoods> = [
{ title: '行号', key: 'lineNo', width: 60, align: 'center' },
{ title: '商品名称', key: 'goodsName', width: 140, ellipsis: { tooltip: true } },
{ title: '税收分类编码', key: 'taxClassificationCode', width: 120, ellipsis: { tooltip: true } },
@@ -931,7 +1053,7 @@ function goodsSummary() {
]
}
const voucherColumns: DataTableColumn[] = [
const voucherColumns: DataTableColumns<InvoiceDetailVoucher> = [
{
title: '凭证类型',
key: 'proofType',
@@ -1045,6 +1167,56 @@ onMounted(() => {
background: #fafafa !important;
}
.sample-preview {
padding: 16px;
}
.sample-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.sample-title {
color: #111827;
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 13px;
font-weight: 600;
}
.sample-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.sample-zoom {
min-width: 46px;
color: #4b5563;
font-size: 13px;
text-align: center;
}
.sample-frame-wrap {
height: 70vh;
min-height: 420px;
overflow: auto;
border: 1px solid #eef1f5;
border-radius: 8px;
background: #f8fafc;
}
.sample-frame {
width: 100%;
height: 100%;
min-height: 680px;
border: 0;
transform-origin: 0 0;
}
.detail-shell {
padding: 0 20px 20px;
}