文件
OmniCard/mpuniapp/pages/owner/nfc/scan.uvue
wikiboss 62e0ae4435 feat: 同步管理端、用户端与后端业务,并接通线上 Web 接口
补齐平台订阅、外部登录、CRM 同步与用户端页面;Nginx 转发用户端接口,登录页使用 Google 与 Apple 标志。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 18:01:47 +08:00

1093 行
26 KiB
Plaintext

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<template>
<view class="scan-page" :class="pageRtlClass" :style="pageTheme">
<scroll-view class="scan-scroll" :scroll-y="true">
<view class="scan-content">
<!-- 1. Hero Showcase Bento -->
<view class="hero-bento">
<!-- Card Style Switcher Pill -->
<view class="card-style-switcher">
<view
class="switcher-pill"
:class="{ 'switcher-pill-active': selectedCardStyle === 'white' }"
@click="toggleCardStyle('white')"
>
<text class="switcher-text">{{ extraText('cardTypeWhite') }}</text>
</view>
<view
class="switcher-pill"
:class="{ 'switcher-pill-active': selectedCardStyle === 'black' }"
@click="toggleCardStyle('black')"
>
<text class="switcher-text">{{ extraText('cardTypeBlack') }}</text>
</view>
</view>
<!-- Physical Card Showcase Stage -->
<view class="card-stage">
<!-- NFC single 400ms pulse ring -->
<view class="scan-ring"></view>
<!-- Physical card twin (White / Black) -->
<view class="nfc-physical-card" :class="'card-theme-' + selectedCardStyle">
<view class="card-glass-specular"></view>
<view class="card-header-row">
<text class="nfc-card-brand">OMNICARD AI</text>
<view class="nfc-icon-wrap">
<view class="nfc-signal-bar bar-1"></view>
<view class="nfc-signal-bar bar-2"></view>
<view class="nfc-signal-bar bar-3"></view>
</view>
</view>
<view class="card-chip-area">
<view class="chip-hardware">
<view class="chip-line"></view>
<view class="chip-line"></view>
</view>
<text class="nfc-card-chip">NTAG 215 · 540B</text>
</view>
<view class="card-footer-row">
<view class="card-uid-info">
<text class="card-uid-caption">UID</text>
<text class="card-uid-val">{{ displayCardUid }}</text>
</view>
<view class="card-badge">
<text class="card-badge-txt">{{ currentCardStatusText }}</text>
</view>
</view>
</view>
</view>
<!-- Title and status indicator -->
<text class="scan-title">{{ pageTitle }}</text>
<text class="scan-desc">{{ t('desc') }}</text>
<view v-if="statusLine !== ''" class="scan-status" :class="{ 'status-error': state.errorMessage !== '' }">
<view class="status-dot"></view>
<text class="scan-status-text">{{ statusLine }}</text>
</view>
</view>
<!-- 2. Activated Success Result Bento -->
<view v-if="state.record != null" class="result-bento">
<view class="result-header">
<view class="result-success-icon">
<text class="result-icon-check">OK</text>
</view>
<view class="result-header-text">
<text class="result-title">{{ extraText('boundSuccessTitle') }}</text>
<text class="result-desc">{{ extraText('boundSuccessDesc') }}</text>
</view>
</view>
<view class="meta-grid">
<view class="meta-cell">
<text class="meta-label">{{ t('uidLabel') }}</text>
<text class="meta-value">{{ state.record!.uid }}</text>
</view>
<view class="meta-cell">
<text class="meta-label">{{ t('cardIdLabel') }}</text>
<text class="meta-value">#{{ state.record!.cardId || state.cardIdRaw || '-' }}</text>
</view>
<view class="meta-cell">
<text class="meta-label">{{ extraText('hardwareSpecs') }}</text>
<text class="meta-value">{{ state.record!.batchNo || 'NTAG215-STD' }}</text>
</view>
<view class="meta-cell">
<text class="meta-label">Scene Mode</text>
<text class="meta-value uppercase-txt">{{ state.record!.sceneMode || 'business' }}</text>
</view>
</view>
<view class="re-scan-btn" @click="handleReset">
<text class="re-scan-text">{{ extraText('actionScanAnother') }}</text>
</view>
</view>
<!-- 3. 3-Step Quick Guide Bento -->
<view v-if="state.record == null" class="guide-bento">
<view class="step-card">
<view class="step-badge">
<text class="step-num">1</text>
</view>
<text class="step-title">{{ extraText('step1Title') }}</text>
<text class="step-desc">{{ extraText('step1Desc') }}</text>
</view>
<view class="step-card">
<view class="step-badge">
<text class="step-num">2</text>
</view>
<text class="step-title">{{ extraText('step2Title') }}</text>
<text class="step-desc">{{ extraText('step2Desc') }}</text>
</view>
<view class="step-card">
<view class="step-badge">
<text class="step-num">3</text>
</view>
<text class="step-title">{{ extraText('step3Title') }}</text>
<text class="step-desc">{{ extraText('step3Desc') }}</text>
</view>
</view>
<!-- 4. Chip UID and Card Input Bento Form (Shown when state.manualInput) -->
<view v-if="state.manualInput && state.record == null" class="scan-form">
<view class="form-header-bar">
<text class="form-title">{{ extraText('hardwareSpecs') }}</text>
<text class="form-badge-proto">{{ extraText('hardwareNtag') }}</text>
</view>
<view class="field">
<text class="field-label">{{ t('uidLabel') }}</text>
<view class="input-wrapper">
<input
class="field-input"
type="text"
v-model="state.uid"
:placeholder="t('uidPlaceholder')"
:disabled="state.submitting"
/>
</view>
</view>
<view class="field">
<text class="field-label">{{ t('cardIdLabel') }}</text>
<view class="input-wrapper">
<input
class="field-input"
type="number"
v-model="state.cardIdRaw"
:placeholder="t('cardIdPlaceholder')"
:disabled="state.submitting"
/>
</view>
</view>
</view>
<view class="scan-spacer"></view>
</view>
</scroll-view>
<!-- 5. Fixed Bottom Primary CTA Bar -->
<view class="scan-cta-bar">
<button
class="primary-cta-btn"
:class="{ 'primary-cta-btn-disabled': isCtaDisabled }"
:disabled="isCtaDisabled"
:aria-label="ctaButtonText"
@click="handleActivate"
>
<text class="primary-cta-btn-text">{{ ctaButtonText }}</text>
</button>
</view>
</view>
</template>
<script setup lang="uts">
import { getCurrentLocale, getOmniText, isRtl } from '../../../common/locale.uts';
import { utsLightColors, utsRadius, utsSpacing, utsTypography } from '../../../common/tokens.uts';
import {
applyScanOptions,
buildNdefUri,
createScanState,
resolveScanText,
resolveStatusLine,
startScan,
submitScan,
writeNdefToTag,
ScanCopyKey,
ScanPageOptions,
} from './useScan.uts';
type ExtraCopyKey =
| 'cardReadyTitle'
| 'cardReadySubtitle'
| 'cardTypeWhite'
| 'cardTypeBlack'
| 'step1Title'
| 'step1Desc'
| 'step2Title'
| 'step2Desc'
| 'step3Title'
| 'step3Desc'
| 'statusSuccess'
| 'statusActive'
| 'statusReady'
| 'hardwareSpecs'
| 'hardwareNtag'
| 'boundSuccessTitle'
| 'boundSuccessDesc'
| 'actionDone'
| 'actionScanAnother'
| 'submittingText';
const pageTheme: Record<string, string> = {
'--oc-primary': utsLightColors.primary,
'--oc-primary-gradient': utsLightColors.primaryGradient,
'--oc-primary-soft': utsLightColors.primarySoft,
'--oc-aurora': utsLightColors.aurora,
'--oc-bg': utsLightColors.bg,
'--oc-surface': utsLightColors.surface,
'--oc-border': utsLightColors.border,
'--oc-text': utsLightColors.text,
'--oc-text-2': utsLightColors.text2,
'--oc-success': utsLightColors.success,
'--oc-warning': utsLightColors.warning,
'--oc-error': utsLightColors.error,
'--oc-radius-btn': utsRadius.button,
'--oc-radius-card': utsRadius.card,
'--oc-radius-modal': utsRadius.modal,
'--oc-radius-pill': utsRadius.pill,
'--oc-shadow-card': utsTypography.shadowCard,
'--oc-shadow-cta': utsTypography.shadowCta,
'--oc-shadow': utsTypography.shadow,
'--oc-space-4': utsSpacing.space4,
'--oc-space-8': utsSpacing.space8,
'--oc-space-12': utsSpacing.space12,
'--oc-space-16': utsSpacing.space16,
'--oc-space-20': utsSpacing.space20,
'--oc-space-24': utsSpacing.space24,
};
const state = reactive(createScanState());
const pageTitle = ref(getOmniText(getCurrentLocale(), 'card.scan_nfc'));
const selectedCardStyle = ref<'white' | 'black'>('white');
const statusLine = computed((): string => {
return resolveStatusLine(state);
});
const pageRtlClass = computed((): string => {
return isRtl(getCurrentLocale()) ? 'scan-rtl' : '';
});
const displayCardUid = computed((): string => {
if (state.record != null && state.record.uid !== '') {
return state.record.uid;
}
if (state.uid !== '') {
return state.uid;
}
return '04:••:••:••:••';
});
const currentCardStatusText = computed((): string => {
if (state.record != null) {
return extraText('statusActive');
}
if (state.uid !== '') {
return 'READY';
}
return extraText('statusReady');
});
const isCtaDisabled = computed((): boolean => {
if (state.submitting) {
return true;
}
return false;
});
const ctaButtonText = computed((): string => {
if (state.submitting) {
return extraText('submittingText');
}
if (state.record != null) {
return extraText('actionDone');
}
return t('cta');
});
function t(key: ScanCopyKey): string {
return resolveScanText(key);
}
function extraText(key: ExtraCopyKey): string {
const isZh = getCurrentLocale() === 'zh-CN';
if (key === 'cardReadyTitle') {
return isZh ? '请将卡片贴近手机背面上部' : 'Hold card to the top-back of phone';
}
if (key === 'cardReadySubtitle') {
return isZh ? '内置 NTAG 芯片感应距离 1-3cm' : 'NTAG chip sensing range 1-3cm';
}
if (key === 'cardTypeWhite') {
return isZh ? '商务白金卡' : 'White Smart Card';
}
if (key === 'cardTypeBlack') {
return isZh ? '极客曜黑卡' : 'Obsidian Black Card';
}
if (key === 'step1Title') {
return isZh ? '贴卡感应' : 'Tap Card';
}
if (key === 'step1Desc') {
return isZh ? '对准背部 NFC 天线' : 'Align with NFC antenna';
}
if (key === 'step2Title') {
return isZh ? '读取 UID' : 'Read UID';
}
if (key === 'step2Desc') {
return isZh ? '获取芯片唯一序列号' : 'Acquire unique chip ID';
}
if (key === 'step3Title') {
return isZh ? '云端激活' : 'Activate';
}
if (key === 'step3Desc') {
return isZh ? '写入绑定微官网' : 'Bind mobile portal';
}
if (key === 'statusSuccess') {
return isZh ? '卡片已成功激活' : 'Card Successfully Activated';
}
if (key === 'statusActive') {
return isZh ? '已激活生效' : 'Active';
}
if (key === 'statusReady') {
return isZh ? '待感应' : 'Ready to Tap';
}
if (key === 'hardwareSpecs') {
return isZh ? '硬件规格' : 'Hardware Spec';
}
if (key === 'hardwareNtag') {
return isZh ? 'ISO/IEC 14443-A · NTAG213/215/216' : 'ISO/IEC 14443-A · NTAG213/215/216';
}
if (key === 'boundSuccessTitle') {
return isZh ? '物理实体卡绑定成功' : 'NFC Card Successfully Bound';
}
if (key === 'boundSuccessDesc') {
return isZh
? '云端路由已绑定。芯片内容需等系统写入 API 成功后才会确认。'
: 'Cloud routing is bound. Chip content is confirmed only after a successful system write.';
}
if (key === 'actionDone') {
return isZh ? '完成' : 'Done';
}
if (key === 'actionScanAnother') {
return isZh ? '激活另一张' : 'Activate Another';
}
if (key === 'submittingText') {
return isZh ? '正在激活绑定...' : 'Activating...';
}
return '';
}
function toggleCardStyle(style: 'white' | 'black'): void {
selectedCardStyle.value = style;
}
function handleReset(): void {
state.record = null;
state.uid = '';
state.empty = true;
state.errorMessage = '';
// #ifdef APP
startScan(state);
// #endif
}
function handleActivate(): void {
if (state.record != null) {
// #ifdef APP
uni.navigateBack();
// #endif
// #ifndef APP
uni.navigateBack({
fail: () => {
uni.reLaunch({ url: '/pages/owner/card/index' });
}
});
// #endif
return;
}
void runActivateAndWrite();
}
async function runActivateAndWrite(): Promise<void> {
const record = await submitScan(state);
if (record == null) {
return;
}
const cardId =
record.cardId != null
? record.cardId
: state.deviceCardId != null
? state.deviceCardId
: state.cardIdRaw;
const uri = buildNdefUri(cardId);
await writeNdefToTag(uri);
}
onLoad((options: ScanPageOptions) => {
applyScanOptions(state, options);
// #ifdef H5
state.manualInput = true;
// #endif
// #ifdef APP
startScan(state);
// #endif
});
</script>
<style scoped>
.scan-page {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--oc-bg, #F7F8FA);
color: var(--oc-text, #101828);
}
.scan-rtl {
direction: rtl;
}
.scan-scroll {
flex: 1;
width: 100%;
height: 100%;
}
.scan-content {
display: flex;
flex-direction: column;
padding-inline: var(--oc-space-16, 16px);
padding-block-start: var(--oc-space-16, 16px);
padding-block-end: calc(96px + var(--window-bottom, 0px) + env(safe-area-inset-bottom, 0px));
}
/* 1. Hero 感应展台 Bento */
.hero-bento {
display: flex;
flex-direction: column;
align-items: center;
background-color: var(--oc-surface, #FFFFFF);
border: 1px solid var(--oc-border, #E3E7ED);
border-radius: var(--oc-radius-card, 20px);
padding: var(--oc-space-20, 20px) var(--oc-space-16, 16px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.04), 0 2px 8px rgba(11, 99, 229, 0.03);
margin-block-end: var(--oc-space-16, 16px);
position: relative;
overflow: hidden;
}
/* 实体卡材质切换 */
.card-style-switcher {
display: flex;
flex-direction: row;
align-items: center;
background-color: var(--oc-bg, #F7F8FA);
padding: 3px;
border-radius: 9999px;
margin-block-end: var(--oc-space-16, 16px);
border: 1px solid var(--oc-border, #E3E7ED);
}
.switcher-pill {
padding: 4px 14px;
border-radius: 9999px;
transition: all 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.switcher-pill-active {
background-color: var(--oc-surface, #FFFFFF);
box-shadow: 0 2px 6px rgba(16, 24, 40, 0.08);
}
.switcher-text {
font-size: 12px;
font-weight: 500;
color: var(--oc-text-2, #667085);
}
.switcher-pill-active .switcher-text {
color: var(--oc-primary, #0B5CFF);
font-weight: 600;
}
/* 拟物实体卡展台区 */
.card-stage {
position: relative;
width: 240px;
height: 152px;
display: flex;
align-items: center;
justify-content: center;
margin-block-end: var(--oc-space-20, 20px);
}
/* NFC 单次脉冲呼吸环 (400ms 一次,不循环) */
.scan-ring {
position: absolute;
top: 50%;
left: 50%;
width: 140px;
height: 140px;
margin-left: -70px;
margin-top: -70px;
box-sizing: border-box;
border-width: 2px;
border-style: solid;
border-color: var(--oc-primary, #0B5CFF);
border-radius: 70px;
opacity: 0.4;
animation-name: scan-pulse;
animation-duration: 400ms;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: 1;
animation-fill-mode: forwards;
pointer-events: none;
z-index: 1;
}
/* 拟物实体卡片外观 */
.nfc-physical-card {
position: relative;
width: 220px;
height: 136px;
border-radius: 16px;
padding: 14px 16px;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: space-between;
box-shadow: 0 12px 28px rgba(16, 24, 40, 0.12), 0 2px 6px rgba(0, 0, 0, 0.04);
z-index: 2;
transition: all 200ms ease;
overflow: hidden;
}
/* 商务白金卡 */
.card-theme-white {
background: linear-gradient(135deg, #FFFFFF 0%, #F0F4F8 100%);
border: 1px solid rgba(255, 255, 255, 0.8);
}
/* 极客曜黑卡 */
.card-theme-black {
background: linear-gradient(135deg, #1C1F26 0%, #0F1217 100%);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.card-glass-specular {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 48px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.18) 0%, rgba(255, 255, 255, 0) 100%);
pointer-events: none;
}
.card-header-row {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.nfc-card-brand {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.8px;
}
.card-theme-white .nfc-card-brand {
color: #101828;
}
.card-theme-black .nfc-card-brand {
color: #F2F4F7;
}
/* 拟物 NFC 信号微图标 */
.nfc-icon-wrap {
display: flex;
flex-direction: row;
align-items: center;
gap: 2px;
}
.nfc-signal-bar {
width: 3px;
border-radius: 2px;
}
.bar-1 {
height: 6px;
}
.bar-2 {
height: 10px;
}
.bar-3 {
height: 14px;
}
.card-theme-white .nfc-signal-bar {
background-color: var(--oc-primary, #0B5CFF);
}
.card-theme-black .nfc-signal-bar {
background-color: #3894FF;
}
/* 拟物芯片位置 */
.card-chip-area {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
}
.chip-hardware {
width: 24px;
height: 18px;
border-radius: 4px;
background: linear-gradient(135deg, #E6CA85 0%, #C4A152 100%);
display: flex;
flex-direction: column;
justify-content: space-evenly;
padding: 2px;
box-sizing: border-box;
}
.chip-line {
height: 1px;
background-color: rgba(0, 0, 0, 0.25);
}
.nfc-card-chip {
font-size: 10px;
font-family: monospace;
}
.card-theme-white .nfc-card-chip {
color: var(--oc-text-2, #667085);
}
.card-theme-black .nfc-card-chip {
color: #94A3B8;
}
.card-footer-row {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-end;
}
.card-uid-info {
display: flex;
flex-direction: column;
}
.card-uid-caption {
font-size: 9px;
font-weight: 500;
text-transform: uppercase;
}
.card-theme-white .card-uid-caption {
color: var(--oc-text-2, #667085);
}
.card-theme-black .card-uid-caption {
color: #94A3B8;
}
.card-uid-val {
font-size: 11px;
font-family: monospace;
font-weight: 600;
}
.card-theme-white .card-uid-val {
color: var(--oc-text, #101828);
}
.card-theme-black .card-uid-val {
color: #F2F4F7;
}
.card-badge {
padding: 2px 8px;
border-radius: 9999px;
}
.card-theme-white .card-badge {
background-color: rgba(0, 136, 255, 0.1);
}
.card-theme-black .card-badge {
background-color: rgba(255, 255, 255, 0.12);
}
.card-badge-txt {
font-size: 10px;
font-weight: 600;
}
.card-theme-white .card-badge-txt {
color: var(--oc-primary, #0B5CFF);
}
.card-theme-black .card-badge-txt {
color: #3894FF;
}
/* 标题与说明 */
.scan-title {
font-size: 20px;
font-weight: 700;
color: var(--oc-text, #101828);
margin-block-end: var(--oc-space-8, 8px);
line-height: 1.25;
text-align: center;
}
.scan-desc {
font-size: 13px;
color: var(--oc-text-2, #667085);
line-height: 1.5;
text-align: center;
margin-block-end: var(--oc-space-12, 12px);
}
.scan-status {
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
background-color: var(--oc-bg, #F7F8FA);
padding: 6px 14px;
border-radius: 9999px;
border: 1px solid var(--oc-border, #E3E7ED);
}
.status-error {
background-color: #FEF3F2;
border-color: #FECDCA;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 3px;
background-color: var(--oc-primary, #0B5CFF);
}
.status-error .status-dot {
background-color: #F04438;
}
.scan-status-text {
font-size: 12px;
color: var(--oc-text, #101828);
line-height: 1.4;
}
.status-error .scan-status-text {
color: #B42318;
}
/* 2. 激活结果展示 Bento */
.result-bento {
background-color: var(--oc-surface, #FFFFFF);
border: 1px solid var(--oc-border, #E3E7ED);
border-radius: var(--oc-radius-card, 20px);
padding: var(--oc-space-16, 16px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.04);
margin-block-end: var(--oc-space-16, 16px);
}
.result-header {
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
margin-block-end: var(--oc-space-16, 16px);
padding-block-end: var(--oc-space-12, 12px);
border-bottom: 1px solid var(--oc-border, #E3E7ED);
}
.result-success-icon {
width: 36px;
height: 36px;
border-radius: 18px;
background-color: #ECFDF3;
display: flex;
align-items: center;
justify-content: center;
}
.result-icon-check {
color: #12B76A;
font-size: 13px;
font-weight: 700;
}
.result-header-text {
flex: 1;
display: flex;
flex-direction: column;
}
.result-title {
font-size: 15px;
font-weight: 600;
color: var(--oc-text, #101828);
}
.result-desc {
font-size: 12px;
color: var(--oc-text-2, #667085);
margin-block-start: 2px;
}
.meta-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 10px;
margin-block-end: var(--oc-space-12, 12px);
}
.meta-cell {
flex: 1;
min-width: 45%;
background-color: var(--oc-bg, #F7F8FA);
border-radius: 12px;
padding: 10px 12px;
display: flex;
flex-direction: column;
}
.meta-label {
font-size: 11px;
color: var(--oc-text-2, #667085);
margin-block-end: 2px;
}
.meta-value {
font-size: 13px;
font-weight: 600;
color: var(--oc-text, #101828);
}
.uppercase-txt {
text-transform: uppercase;
}
.re-scan-btn {
display: flex;
align-items: center;
justify-content: center;
padding-block: 8px;
}
.re-scan-text {
font-size: 13px;
font-weight: 500;
color: var(--oc-primary, #0B5CFF);
}
/* 3. 三步极速激活指引 Bento */
.guide-bento {
display: flex;
flex-direction: row;
gap: 8px;
margin-block-end: var(--oc-space-16, 16px);
}
.step-card {
flex: 1;
background-color: var(--oc-surface, #FFFFFF);
border: 1px solid var(--oc-border, #E3E7ED);
border-radius: 16px;
padding: 12px 10px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.02);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.step-badge {
width: 22px;
height: 22px;
border-radius: 11px;
background-color: rgba(0, 136, 255, 0.1);
display: flex;
align-items: center;
justify-content: center;
margin-block-end: 6px;
}
.step-num {
font-size: 11px;
font-weight: 700;
color: var(--oc-primary, #0B5CFF);
}
.step-title {
font-size: 12px;
font-weight: 600;
color: var(--oc-text, #101828);
margin-block-end: 2px;
}
.step-desc {
font-size: 10px;
color: var(--oc-text-2, #667085);
line-height: 1.3;
}
/* 4. 表单与硬件参数 Bento */
.scan-form {
background-color: var(--oc-surface, #FFFFFF);
border: 1px solid var(--oc-border, #E3E7ED);
border-radius: var(--oc-radius-card, 20px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.04);
padding: var(--oc-space-16, 16px);
display: flex;
flex-direction: column;
}
.form-header-bar {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-block-end: var(--oc-space-12, 12px);
padding-block-end: var(--oc-space-8, 8px);
border-bottom: 1px solid var(--oc-border, #E3E7ED);
}
.form-title {
font-size: 13px;
font-weight: 600;
color: var(--oc-text, #101828);
}
.form-badge-proto {
font-size: 10px;
color: var(--oc-text-2, #667085);
background-color: var(--oc-bg, #F7F8FA);
padding: 2px 8px;
border-radius: 9999px;
border: 1px solid var(--oc-border, #E3E7ED);
}
.field {
display: flex;
flex-direction: column;
margin-block-end: var(--oc-space-12, 12px);
}
.field:last-child {
margin-block-end: 0;
}
.field-label {
font-size: 13px;
color: var(--oc-text, #101828);
font-weight: 600;
margin-block-end: 6px;
}
.input-wrapper {
background-color: var(--oc-bg, #F7F8FA);
border: 1px solid var(--oc-border, #E3E7ED);
border-radius: var(--oc-radius-btn, 14px);
padding-inline: var(--oc-space-12, 12px);
transition: border-color 150ms ease;
}
.field-input {
height: 44px;
font-size: 15px;
color: var(--oc-text, #101828);
border-width: 0;
background-color: transparent;
}
.scan-spacer {
height: 16px;
}
/* 5. 底部固定 CTA Bar */
.scan-cta-bar {
position: fixed;
left: 0;
right: 0;
bottom: var(--window-bottom, 0px);
padding: 12px var(--oc-space-16, 16px) calc(16px + env(safe-area-inset-bottom, 0px));
background-color: var(--oc-surface, #FFFFFF);
border-top: 1px solid var(--oc-border, #E3E7ED);
box-sizing: border-box;
z-index: 100;
}
.primary-cta-btn {
height: 48px;
min-height: 44px;
background-color: var(--oc-primary, #0B5CFF);
border-radius: var(--oc-radius-btn, 14px);
display: flex;
align-items: center;
justify-content: center;
width: 100%;
border-width: 0;
box-shadow: 0 8px 20px rgba(0, 136, 255, 0.22);
transition: opacity 150ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.primary-cta-btn:active {
opacity: 0.85;
}
.primary-cta-btn-disabled {
background-color: var(--oc-border, #E3E7ED);
box-shadow: none;
}
.primary-cta-btn-text {
color: #FFFFFF;
font-size: 16px;
font-weight: 600;
}
.primary-cta-btn-disabled .primary-cta-btn-text {
color: var(--oc-text-2, #667085);
}
/* 脉冲微动画:400ms 单次,不循环(仅 opacity,无 scale) */
@keyframes scan-pulse {
0% {
opacity: 0.4;
}
50% {
opacity: 1;
}
100% {
opacity: 0.4;
}
}
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
transition: none !important;
}
.primary-cta-btn {
transition: none;
}
.scan-ring {
opacity: 1;
}
}
</style>