微信小程序NFC读写案例
·6 字·1 分钟阅读
小程序
index.wxml
<view class="page">
<!-- ====== 顶部状态栏 ====== -->
<view class="status-bar">
<view class="status-dot {{nfcSupported ? 'dot-active' : 'dot-inactive'}}"></view>
<text class="status-text">{{nfcSupported ? 'NFC 已就绪' : '设备不支持 NFC'}}</text>
<view class="mode-tag {{isWriting ? 'mode-writing' : 'mode-reading'}}">
{{isWriting ? '等待写入' : '读取中'}}
</view>
</view>
<!-- ====== 标题区 ====== -->
<view class="hero">
<text class="hero-icon">📡</text>
<text class="hero-title">NFC 读写工具</text>
<text class="hero-sub">靠近标签自动识别 · 写入内容秒级完成</text>
</view>
<!-- ====== NFC 扫描动画 ====== -->
<view class="scan-area {{nfcTagData ? 'scan-detected' : 'scan-idle'}}">
<view class="scan-ring-1"></view>
<view class="scan-ring-2"></view>
<view class="scan-ring-3"></view>
<view class="scan-icon">{{nfcTagData ? '✅' : '📶'}}</view>
</view>
<!-- ====== 写入倒计时浮层 ====== -->
<view class="write-overlay {{isWriting ? 'overlay-show' : ''}}">
<view class="write-overlay-inner">
<view class="countdown-ring">
<text class="countdown-num">{{writeCountdown}}</text>
<text class="countdown-label">秒</text>
</view>
<text class="write-hint">请将手机靠近 NFC 标签</text>
<view class="write-progress-bar">
<view class="write-progress-fill" style="width: {{(5 - writeCountdown) / 5 * 100}}%;"></view>
</view>
<button class="btn-cancel" bindtap="cancelWrite">取消写入</button>
</view>
</view>
<!-- ====== 标签信息卡片 ====== -->
<view class="tag-card {{nfcTagData ? 'card-show' : 'card-hide'}}">
<view class="card-header">
<text class="card-title">📋 标签详情</text>
<text class="card-time">{{detectTime}}</text>
</view>
<view class="info-row">
<text class="info-label">UID</text>
<text class="info-value mono">{{nfcTagData.uid || '—'}}</text>
</view>
<view class="info-row">
<text class="info-label">技术协议</text>
<text class="info-value">{{nfcTagData.techsStr || '—'}}</text>
</view>
<!-- NDEF 记录列表 -->
<view class="ndef-section" wx:if="{{nfcTagData.ndefRecords && nfcTagData.ndefRecords.length > 0}}">
<text class="ndef-section-title">NDEF 记录</text>
<view class="ndef-item" wx:for="{{nfcTagData.ndefRecords}}" wx:key="index">
<view class="ndef-meta">
<text class="ndef-index">#{{item.index + 1}}</text>
<text class="ndef-type">TNF={{item.tnf}} Type={{item.type}}</text>
</view>
<view class="ndef-payload">
<text class="ndef-label">内容</text>
<text class="ndef-value">{{item.decodedPayload || '(空)'}}</text>
</view>
<view class="ndef-status {{item.decodeStatus.indexOf('成功') > -1 ? 'status-ok' : 'status-warn'}}">
{{item.decodeStatus}}
</view>
</view>
</view>
<view class="ndef-empty" wx:else>
<text class="ndef-empty-text">该标签暂无 NDEF 数据</text>
</view>
</view>
<!-- ====== 写入输入区 ====== -->
<view class="write-section">
<view class="input-wrapper">
<input
class="write-input"
maxlength="50"
placeholder="输入要写入 NFC 标签的内容..."
placeholder-class="input-placeholder"
value="{{inputValue}}"
bindinput="handleInput"
disabled="{{isWriting}}"
/>
<text class="input-counter">{{inputValue.length}}/50</text>
</view>
<button
class="btn-write {{isWriting ? 'btn-disabled' : ''}}"
disabled="{{isWriting}}"
bindtap="startWrite"
>
<text class="btn-icon">✏️</text>
<text>写入 NFC</text>
</button>
<text class="write-tip" wx:if="{{isWriting}}">写入模式已开启,靠近标签自动写入...</text>
<text class="write-tip" wx:else>点击按钮后 5 秒内靠近标签即可写入</text>
</view>
</view>
index.js
let nfcAdapter = null;
let ndef = null;
let writeTimer = null;
const methods = {
utf8ArrayToStr(uint8Array) {
let finalStr = "";
for (let i = 0; i < uint8Array.length; ) {
const code = uint8Array[i];
if (code <= 0x7f) {
finalStr += String.fromCharCode(code);
i++;
} else if (code >= 0xc0 && code <= 0xdf) {
const charCode = ((code & 0x1f) << 6) | (uint8Array[i + 1] & 0x3f);
finalStr += String.fromCharCode(charCode);
i += 2;
} else if (code >= 0xe0 && code <= 0xef) {
const charCode =
((code & 0x0f) << 12) |
((uint8Array[i + 1] & 0x3f) << 6) |
(uint8Array[i + 2] & 0x3f);
finalStr += String.fromCharCode(charCode);
i += 3;
} else {
i++;
}
}
return finalStr;
},
parseNfcData(nfcRes) {
const uid = nfcRes.id
? Array.prototype.map
.call(new Uint8Array(nfcRes.id), (x) =>
("00" + x.toString(16)).slice(-2),
)
.join(":")
.toUpperCase()
: "未知";
const techs = nfcRes.techs || [];
const techNames = {
"NFC-A": "NFC-A (Type 1/2)",
"NFC-B": "NFC-B (Type 4)",
"NFC-F": "NFC-F (Felica)",
"NFC-V": "NFC-V (Vicinity)",
ISO_DEP: "ISO-DEP (ISO 14443-4)",
NDEF: "NDEF",
};
const techsStr = techs.map((t) => techNames[t] || t).join("、") || "未知";
let ndefRecords = [];
if (nfcRes.messages && nfcRes.messages.length > 0) {
nfcRes.messages.forEach((msg) => {
if (msg.records && msg.records.length > 0) {
msg.records.forEach((record, recIndex) => {
const tnf = record.tnf;
const type = record.type
? String.fromCharCode.apply(null, new Uint8Array(record.type))
: "";
const payload = record.payload
? new Uint8Array(record.payload)
: null;
let decodedPayload = "";
let decodeStatus = "";
if (payload && payload.length > 0) {
try {
if (tnf === 1 && type === "T" && payload.length > 1) {
const statusByte = payload[0];
const langCodeLen = statusByte & 0x3f;
const isUtf16 = (statusByte & 0x80) !== 0;
const textStartIndex = 1 + langCodeLen;
const textBytes = payload.slice(textStartIndex);
if (isUtf16) {
decodeStatus = "UTF-16 编码暂不支持";
} else {
decodedPayload = this.utf8ArrayToStr(textBytes);
decodeStatus = "UTF-8 文本解码成功";
}
} else {
decodedPayload = this.utf8ArrayToStr(payload);
decodeStatus = "默认 UTF-8 解码";
}
} catch (e) {
decodedPayload = "解码失败";
decodeStatus = "解码异常: " + e.message;
}
}
ndefRecords.push({
index: recIndex,
tnf,
type,
decodedPayload,
decodeStatus,
});
});
}
});
}
return { uid, techs, techsStr, ndefRecords };
},
};
Page({
data: {
inputValue: "",
nfcSupported: true,
isWriting: false,
writeInProgress: false,
writeCountdown: 5,
nfcTagData: null,
detectTime: "",
},
onLoad() {
this.initNFC();
},
onUnload() {
this.clearWriteTimer();
if (nfcAdapter) {
try {
nfcAdapter.stopDiscovery();
nfcAdapter.offDiscovered();
} catch (e) {}
}
},
initNFC() {
try {
nfcAdapter = wx.getNFCAdapter();
} catch (e) {
this.setData({ nfcSupported: false });
wx.showToast({ title: "设备不支持 NFC", icon: "none" });
return;
}
if (!nfcAdapter) {
this.setData({ nfcSupported: false });
return;
}
ndef = nfcAdapter.getNdef();
nfcAdapter.onDiscovered((res) => {
if (this.data.isWriting) {
this.handleWriteDiscovery(res);
} else {
this.handleReadDiscovery(res);
}
});
nfcAdapter.startDiscovery({
success: () => {},
fail: () => {
wx.showToast({ title: "NFC 启动失败,请检查权限", icon: "none" });
},
});
},
handleReadDiscovery(res) {
const parsedData = methods.parseNfcData(res);
const now = new Date();
const timeStr =
now.getHours().toString().padStart(2, "0") +
":" +
now.getMinutes().toString().padStart(2, "0") +
":" +
now.getSeconds().toString().padStart(2, "0");
this.setData({
nfcTagData: parsedData,
detectTime: timeStr,
});
},
handleWriteDiscovery(res) {
if (this.data.writeInProgress) return;
const text = this.data.inputValue;
if (!text) {
this.clearWriteTimer();
this.setData({ isWriting: false });
wx.showToast({ title: "请输入写入内容", icon: "none" });
return;
}
this.clearWriteTimer();
this.setData({ writeInProgress: true });
const tech = res.techs && res.techs.length > 0 ? res.techs[0] : "NFC-A";
wx.showLoading({ title: "连接标签中..." });
ndef.connect({
tech: tech,
success: () => {
wx.hideLoading();
wx.showLoading({ title: "写入中..." });
const textBytes = [];
for (let i = 0; i < text.length; i++) {
let code = text.charCodeAt(i);
if (code <= 0x7f) {
textBytes.push(code);
} else if (code <= 0x7ff) {
textBytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f));
} else {
textBytes.push(
0xe0 | (code >> 12),
0x80 | ((code >> 6) & 0x3f),
0x80 | (code & 0x3f),
);
}
}
const langCode = [0x65, 0x6e];
const payloadArray = [0x02, ...langCode, ...textBytes];
const payloadBuffer = new Uint8Array(payloadArray).buffer;
const ndefRecord = {
tnf: 1,
type: new Uint8Array([0x54]).buffer,
id: new ArrayBuffer(0),
payload: payloadBuffer,
};
ndef.writeNdefMessage({
records: [ndefRecord],
success: () => {
wx.hideLoading();
this.setData({ isWriting: false, writeInProgress: false });
wx.showToast({ title: "✅ 写入成功", icon: "success" });
},
fail: (err) => {
wx.hideLoading();
this.setData({ isWriting: false, writeInProgress: false });
wx.showToast({ title: err.errMsg || "写入失败", icon: "none" });
},
});
},
fail: (err) => {
wx.hideLoading();
this.setData({ isWriting: false, writeInProgress: false });
wx.showToast({ title: "连接标签失败,请重试", icon: "none" });
},
});
},
startWrite() {
const text = this.data.inputValue;
if (!text) {
wx.showToast({ title: "请先输入要写入的内容", icon: "none" });
return;
}
this.setData({
isWriting: true,
writeCountdown: 5,
});
const that = this;
writeTimer = setInterval(() => {
let count = that.data.writeCountdown;
count--;
if (count <= 0) {
that.clearWriteTimer();
that.setData({ isWriting: false, writeInProgress: false, writeCountdown: 5 });
wx.showToast({ title: "⏰ 写入超时,未检测到标签", icon: "none" });
} else {
that.setData({ writeCountdown: count });
}
}, 1000);
},
cancelWrite() {
this.clearWriteTimer();
this.setData({ isWriting: false, writeInProgress: false, writeCountdown: 5 });
wx.showToast({ title: "已取消写入", icon: "none" });
},
clearWriteTimer() {
if (writeTimer) {
clearInterval(writeTimer);
writeTimer = null;
}
},
handleInput(e) {
this.setData({ inputValue: e.detail.value });
},
});
index.wxss
/* ====== 全局变量 ====== */
page {
--bg-primary: #0B0F1A;
--bg-card: rgba(18, 26, 45, 0.85);
--bg-card-hover: rgba(24, 34, 56, 0.9);
--border-glow: rgba(0, 212, 255, 0.15);
--accent-cyan: #00D4FF;
--accent-green: #00E676;
--accent-amber: #FFB300;
--accent-red: #FF5252;
--text-primary: #E8EAED;
--text-secondary: rgba(232, 234, 237, 0.6);
--text-muted: rgba(232, 234, 237, 0.35);
--radius-sm: 12rpx;
--radius-md: 20rpx;
--radius-lg: 28rpx;
}
/* ====== 页面容器 ====== */
.page {
min-height: 100vh;
padding: 40rpx 32rpx 80rpx;
background:
radial-gradient(ellipse at 50% 0%, rgba(0, 212, 255, 0.04) 0%, transparent 60%),
radial-gradient(ellipse at 80% 100%, rgba(0, 230, 118, 0.03) 0%, transparent 50%),
var(--bg-primary);
display: flex;
flex-direction: column;
align-items: center;
}
/* ====== 顶部状态栏 ====== */
.status-bar {
width: 100%;
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 32rpx;
}
.status-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
flex-shrink: 0;
}
.dot-active {
background: var(--accent-green);
box-shadow: 0 0 12rpx rgba(0, 230, 118, 0.6);
animation: pulse-dot 2s ease-in-out infinite;
}
.dot-inactive {
background: var(--accent-red);
box-shadow: 0 0 12rpx rgba(255, 82, 82, 0.4);
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.status-text {
font-size: 26rpx;
color: var(--text-secondary);
font-weight: 500;
flex: 1;
}
.mode-tag {
font-size: 22rpx;
padding: 8rpx 20rpx;
border-radius: 100rpx;
font-weight: 500;
}
.mode-reading {
background: rgba(0, 212, 255, 0.12);
color: var(--accent-cyan);
border: 2rpx solid rgba(0, 212, 255, 0.3);
}
.mode-writing {
background: rgba(255, 179, 0, 0.12);
color: var(--accent-amber);
border: 2rpx solid rgba(255, 179, 0, 0.3);
animation: pulse-mode 1s ease-in-out infinite;
}
@keyframes pulse-mode {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
/* ====== 标题区 ====== */
.hero {
text-align: center;
margin-bottom: 48rpx;
}
.hero-icon {
font-size: 64rpx;
display: block;
margin-bottom: 12rpx;
}
.hero-title {
font-size: 40rpx;
font-weight: 700;
color: var(--text-primary);
letter-spacing: 2rpx;
display: block;
}
.hero-sub {
font-size: 24rpx;
color: var(--text-muted);
display: block;
margin-top: 8rpx;
}
/* ====== NFC 扫描动画区 ====== */
.scan-area {
position: relative;
width: 240rpx;
height: 240rpx;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 48rpx;
}
.scan-ring-1,
.scan-ring-2,
.scan-ring-3 {
position: absolute;
border-radius: 50%;
border: 2rpx solid transparent;
}
.scan-idle .scan-ring-1 {
width: 240rpx;
height: 240rpx;
border-color: rgba(0, 212, 255, 0.08);
animation: scan-ripple 3s ease-out infinite;
}
.scan-idle .scan-ring-2 {
width: 200rpx;
height: 200rpx;
border-color: rgba(0, 212, 255, 0.12);
animation: scan-ripple 3s ease-out 0.6s infinite;
}
.scan-idle .scan-ring-3 {
width: 160rpx;
height: 160rpx;
border-color: rgba(0, 212, 255, 0.16);
animation: scan-ripple 3s ease-out 1.2s infinite;
}
.scan-detected .scan-ring-1 {
width: 260rpx;
height: 260rpx;
border-color: rgba(0, 230, 118, 0.3);
animation: scan-success 1.5s ease-out;
}
.scan-detected .scan-ring-2 {
width: 220rpx;
height: 220rpx;
border-color: rgba(0, 230, 118, 0.2);
animation: scan-success 1.5s ease-out 0.3s;
}
.scan-detected .scan-ring-3 {
width: 180rpx;
height: 180rpx;
border-color: rgba(0, 230, 118, 0.15);
animation: scan-success 1.5s ease-out 0.6s;
}
@keyframes scan-ripple {
0% { transform: scale(0.8); opacity: 0; }
20% { opacity: 1; }
100% { transform: scale(1.2); opacity: 0; }
}
@keyframes scan-success {
0% { transform: scale(0.6); opacity: 0; }
40% { opacity: 1; }
100% { transform: scale(1.1); opacity: 0; }
}
.scan-icon {
font-size: 56rpx;
z-index: 2;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-12rpx); }
}
/* ====== 标签信息卡片 ====== */
.tag-card {
width: 100%;
background: var(--bg-card);
backdrop-filter: blur(20rpx);
-webkit-backdrop-filter: blur(20rpx);
border-radius: var(--radius-lg);
border: 2rpx solid var(--border-glow);
padding: 32rpx;
margin-bottom: 32rpx;
transition: all 0.5s cubic-bezier(0.22, 1, 0.36, 1);
overflow: hidden;
}
.card-show {
opacity: 1;
transform: translateY(0);
max-height: 1600rpx;
}
.card-hide {
opacity: 0;
transform: translateY(30rpx);
max-height: 0;
padding: 0 32rpx;
margin-bottom: 0;
border-width: 0;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 28rpx;
padding-bottom: 20rpx;
border-bottom: 2rpx solid rgba(0, 212, 255, 0.08);
}
.card-title {
font-size: 30rpx;
font-weight: 600;
color: var(--text-primary);
}
.card-time {
font-size: 22rpx;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
/* ====== 信息行 ====== */
.info-row {
display: flex;
align-items: center;
padding: 16rpx 0;
border-bottom: 2rpx solid rgba(255, 255, 255, 0.03);
}
.info-row:last-of-type {
border-bottom: none;
}
.info-label {
font-size: 26rpx;
color: var(--text-muted);
width: 140rpx;
flex-shrink: 0;
}
.info-value {
font-size: 26rpx;
color: var(--text-primary);
flex: 1;
word-break: break-all;
}
.info-value.mono {
font-family: "SF Mono", "Menlo", "Courier New", monospace;
color: var(--accent-cyan);
font-size: 24rpx;
letter-spacing: 1rpx;
}
/* ====== NDEF 记录区 ====== */
.ndef-section {
margin-top: 24rpx;
}
.ndef-section-title {
font-size: 26rpx;
font-weight: 600;
color: var(--accent-cyan);
display: block;
margin-bottom: 16rpx;
}
.ndef-item {
background: rgba(0, 212, 255, 0.03);
border: 2rpx solid rgba(0, 212, 255, 0.08);
border-radius: var(--radius-md);
padding: 20rpx;
margin-bottom: 16rpx;
}
.ndef-item:last-child {
margin-bottom: 0;
}
.ndef-meta {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 12rpx;
}
.ndef-index {
font-size: 22rpx;
color: var(--text-primary);
font-weight: 600;
background: rgba(0, 212, 255, 0.1);
padding: 4rpx 16rpx;
border-radius: var(--radius-sm);
}
.ndef-type {
font-size: 22rpx;
color: var(--text-muted);
font-family: "SF Mono", "Menlo", "Courier New", monospace;
}
.ndef-payload {
display: flex;
align-items: flex-start;
gap: 16rpx;
margin-bottom: 12rpx;
}
.ndef-label {
font-size: 24rpx;
color: var(--text-muted);
flex-shrink: 0;
line-height: 1.6;
}
.ndef-value {
font-size: 28rpx;
color: var(--text-primary);
font-weight: 500;
line-height: 1.6;
word-break: break-all;
}
.ndef-status {
font-size: 22rpx;
padding: 6rpx 16rpx;
border-radius: var(--radius-sm);
display: inline-block;
}
.status-ok {
color: var(--accent-green);
background: rgba(0, 230, 118, 0.08);
}
.status-warn {
color: var(--accent-amber);
background: rgba(255, 179, 0, 0.08);
}
.ndef-empty {
margin-top: 24rpx;
text-align: center;
padding: 40rpx 0;
}
.ndef-empty-text {
font-size: 26rpx;
color: var(--text-muted);
}
/* ====== 写入输入区 ====== */
.write-section {
width: 100%;
background: var(--bg-card);
backdrop-filter: blur(20rpx);
-webkit-backdrop-filter: blur(20rpx);
border-radius: var(--radius-lg);
border: 2rpx solid var(--border-glow);
padding: 32rpx;
}
.input-wrapper {
position: relative;
margin-bottom: 24rpx;
}
.write-input {
width: 100%;
height: 100rpx;
background: rgba(0, 0, 0, 0.3);
border: 2rpx solid rgba(255, 255, 255, 0.06);
border-radius: var(--radius-md);
padding: 24rpx 28rpx;
font-size: 28rpx;
color: var(--text-primary);
box-sizing: border-box;
transition: border-color 0.3s;
}
.write-input:focus {
border-color: rgba(0, 212, 255, 0.4);
}
.write-input[disabled] {
opacity: 0.5;
}
.input-placeholder {
color: var(--text-muted);
font-size: 28rpx;
}
.input-counter {
position: absolute;
right: 20rpx;
bottom: 16rpx;
font-size: 22rpx;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.btn-write {
width: 100%;
height: 96rpx;
line-height: 96rpx;
text-align: center;
border-radius: var(--radius-md);
font-size: 30rpx;
font-weight: 600;
color: #fff;
background: linear-gradient(135deg, #00B4D8, #0077B6);
border: none;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
transition: all 0.3s;
position: relative;
overflow: hidden;
}
.btn-write::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.15), transparent);
transform: translateX(-100%);
transition: none;
}
.btn-write:active::after {
transform: translateX(100%);
transition: transform 0.6s;
}
.btn-write:active {
transform: scale(0.97);
}
.btn-disabled {
opacity: 0.4;
transform: none !important;
}
.btn-icon {
font-size: 32rpx;
}
.write-tip {
display: block;
text-align: center;
font-size: 24rpx;
color: var(--text-muted);
margin-top: 20rpx;
}
/* ====== 写入倒计时浮层 ====== */
.write-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(12rpx);
-webkit-backdrop-filter: blur(12rpx);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
opacity: 0;
pointer-events: none;
transition: opacity 0.4s cubic-bezier(0.22, 1, 0.36, 1);
}
.overlay-show {
opacity: 1;
pointer-events: auto;
}
.write-overlay-inner {
width: 500rpx;
background: linear-gradient(145deg, rgba(18, 26, 45, 0.95), rgba(8, 14, 30, 0.98));
border: 2rpx solid rgba(0, 212, 255, 0.2);
border-radius: var(--radius-lg);
padding: 60rpx 48rpx;
display: flex;
flex-direction: column;
align-items: center;
box-shadow:
0 0 60rpx rgba(0, 212, 255, 0.06),
inset 0 0 60rpx rgba(0, 212, 255, 0.02);
}
.countdown-ring {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
border: 6rpx solid rgba(0, 212, 255, 0.15);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-bottom: 32rpx;
position: relative;
}
.countdown-ring::before {
content: '';
position: absolute;
inset: -6rpx;
border-radius: 50%;
border: 6rpx solid var(--accent-cyan);
border-color: var(--accent-cyan) transparent transparent transparent;
animation: spin 2s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.countdown-num {
font-size: 64rpx;
font-weight: 700;
color: var(--accent-cyan);
font-variant-numeric: tabular-nums;
line-height: 1;
}
.countdown-label {
font-size: 24rpx;
color: var(--text-muted);
margin-top: 4rpx;
}
.write-hint {
font-size: 28rpx;
color: var(--text-secondary);
margin-bottom: 28rpx;
text-align: center;
}
.write-progress-bar {
width: 100%;
height: 6rpx;
background: rgba(255, 255, 255, 0.06);
border-radius: 6rpx;
overflow: hidden;
margin-bottom: 36rpx;
}
.write-progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent-cyan), var(--accent-green));
border-radius: 6rpx;
transition: width 1s linear;
}
.btn-cancel {
min-width: 240rpx;
height: 80rpx;
line-height: 80rpx;
text-align: center;
border-radius: 100rpx;
font-size: 28rpx;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.06);
border: 2rpx solid rgba(255, 255, 255, 0.08);
}
.btn-cancel:active {
background: rgba(255, 255, 255, 0.1);
transform: scale(0.97);
}