修复BUG1.0

This commit is contained in:
taiyi 2026-05-03 21:44:16 +08:00
parent 1a03509b40
commit ad510a1be6
44 changed files with 2834 additions and 0 deletions

View File

@ -0,0 +1,54 @@
const app = getApp()
Page({
data: {
result: null,
fileName: '',
loading: false,
error: '',
},
downloadTemplate() {
wx.downloadFile({
url: `${app.globalData.baseUrl}/api/v1/entries/template`,
success: (res) => {
if (res.statusCode !== 200) {
this.setData({ error: '模板下载失败' })
return
}
wx.openDocument({
filePath: res.tempFilePath,
fileType: 'xlsx',
fail: () => this.setData({ error: '模板打开失败,请先保存到本地再上传' }),
})
},
fail: () => this.setData({ error: '模板下载失败' }),
})
},
chooseFile() {
wx.chooseMessageFile({
count: 1,
type: 'file',
extension: ['xlsx'],
success: (res) => {
const file = res.tempFiles[0]
this.setData({ fileName: file.name, loading: true, error: '' })
wx.uploadFile({
url: `${app.globalData.baseUrl}/api/v1/entries/upload-file`,
filePath: file.path,
name: 'file',
header: { 'X-OpenID': app.globalData.openid || '' },
success: (uploadRes) => {
try {
const payload = JSON.parse(uploadRes.data)
this.setData({ result: payload.data || null })
} catch (e) {
this.setData({ error: '上传结果解析失败' })
}
},
fail: () => this.setData({ error: '文件上传失败' }),
complete: () => this.setData({ loading: false }),
})
},
})
},
})

View File

@ -0,0 +1,3 @@
{
"usingComponents": {}
}

View File

@ -0,0 +1,25 @@
<view class="page">
<view class="topbar">
<view class="brand">
<view class="avatar-wrap"><image class="avatar" src="https://picsum.photos/280" mode="aspectFill" /></view>
<text class="brand-text">VocabMaster</text>
</view>
</view>
<view class="content">
<view class="header">
<text class="title">批量上传</text>
<text class="subtitle">先下载模板,按模板填写后直接上传单词或句子。</text>
</view>
<view class="action-row">
<button class="action-btn ghost-btn" bindtap="downloadTemplate">下载模板</button>
<button class="action-btn primary-btn" bindtap="chooseFile">选择文件上传</button>
</view>
<view wx:if="{{fileName}}" class="info">已选择:{{fileName}}</view>
<view wx:if="{{error}}" class="error">{{error}}</view>
<view wx:if="{{result}}" class="result-card">
<text>总数:{{result.total}}</text>
<text>成功:{{result.success}}</text>
<text>失败:{{result.failed}}</text>
</view>
</view>
</view>

View File

@ -0,0 +1 @@
page{background:#0c0e12;color:#f6f6fc}.page{min-height:100vh;background:#0c0e12;box-sizing:border-box}.topbar{display:flex;justify-content:space-between;align-items:center;padding:24rpx 32rpx;background:rgba(12,14,18,.8);backdrop-filter:blur(24px);border-bottom:1rpx solid rgba(255,255,255,.08);box-shadow:0 1rpx 20rpx rgba(0,0,0,.08)}.brand{display:flex;align-items:center;gap:18rpx}.avatar-wrap{width:64rpx;height:64rpx;border-radius:9999rpx;overflow:hidden;border:1rpx solid rgba(255,255,255,.1);flex-shrink:0}.avatar{width:100%;height:100%}.brand-text{font-size:38rpx;font-weight:800;color:#c19cff}.icon-btn{width:72rpx;height:72rpx;border-radius:9999rpx;background:rgba(255,255,255,.06);display:flex;align-items:center;justify-content:center}.content{height:calc(100vh - 120rpx);padding:40rpx 32rpx 32rpx;box-sizing:border-box}.header{margin-bottom:28rpx}.title{display:block;font-size:52rpx;font-weight:800}.subtitle{display:block;margin-top:10rpx;color:#aaabb0;font-size:24rpx}.layout{display:grid;grid-template-columns:1fr;gap:24rpx}.instruction-card,.upload-card,.feedback-card,.step-card,.dropzone{background:rgba(29,32,37,.56);border:1rpx solid rgba(255,255,255,.08);border-radius:28rpx;box-sizing:border-box}.instruction-card{padding:24rpx}.section-title{display:block;font-size:28rpx;font-weight:700;margin-bottom:18rpx}.steps{display:flex;flex-direction:column;gap:18rpx}.step{display:flex;align-items:stretch;gap:16rpx}.step-dot{width:44rpx;height:44rpx;border-radius:9999rpx;background:#23262c;color:#aaabb0;display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:20rpx;font-weight:700;margin-top:10rpx}.active-dot{background:#c19cff;color:#000}.step-card{flex:1;padding:18rpx;background:#171a1f}.low{background:#111318}.step-title{display:block;font-size:24rpx;font-weight:700;margin-bottom:6rpx}.step-desc{display:block;font-size:20rpx;color:#aaabb0}.download{display:block;margin-top:12rpx;font-size:20rpx;color:#00e5ff}.upload-card{display:flex;flex-direction:column;gap:18rpx}.dropzone{padding:40rpx 28rpx;min-height:360rpx;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;background:rgba(29,32,37,.4)}.upload-icon{width:112rpx;height:112rpx;border-radius:9999rpx;background:rgba(255,255,255,.06);display:flex;align-items:center;justify-content:center;font-size:48rpx;color:#c19cff;margin-bottom:18rpx}.drop-title{display:block;font-size:34rpx;font-weight:800;margin-bottom:10rpx}.drop-desc{display:block;font-size:22rpx;color:#aaabb0;line-height:1.6;margin-bottom:24rpx}.action-row{display:flex;gap:20rpx}.action-btn{flex:1;border-radius:9999rpx;padding:22rpx 24rpx;font-size:28rpx;font-weight:700;line-height:1;text-align:center;box-sizing:border-box;border:1rpx solid transparent}.primary-btn{background:linear-gradient(90deg,#c19cff,#9146ff);color:#fff;box-shadow:0 16rpx 32rpx rgba(145,70,255,.22)}.ghost-btn{background:rgba(255,255,255,.04);color:#f6f6fc;border-color:rgba(255,255,255,.1)}.action-btn::after{border:none}.primary-btn:active{opacity:.92;transform:translateY(1rpx)}.ghost-btn:active{background:rgba(255,255,255,.08)}.info{margin-top:18rpx;padding:16rpx 20rpx;border-radius:20rpx;background:rgba(255,255,255,.04);color:#d7d7de;font-size:22rpx}.feedback-card{padding:24rpx;display:flex;flex-direction:column;gap:16rpx}.feedback-left{display:flex;align-items:center;gap:16rpx}.status-icon{width:56rpx;height:56rpx;border-radius:9999rpx;background:#111318;display:flex;align-items:center;justify-content:center;color:#00e5ff}.feedback-title{display:block;font-size:24rpx;font-weight:700}.feedback-file{display:block;font-size:20rpx;color:#aaabb0}.feedback-stats{display:flex;align-items:center;gap:24rpx;justify-content:flex-end}.stat{text-align:center}.stat-num{display:block;font-size:34rpx;font-weight:800}.good{color:#00e5ff}.bad{color:#aaabb0}.stat-label{display:block;font-size:18rpx;color:#aaabb0}.divider{width:1rpx;height:34rpx;background:rgba(255,255,255,.08)}

View File

@ -0,0 +1,70 @@
const app = getApp()
Page({
data: {
entry: null,
loading: false,
error: '',
},
onLoad(options) {
this.entryId = options.id
this.loadEntry()
},
loadEntry() {
if (!app.globalData.openid) {
this.setData({ error: '????' })
return
}
if (!this.entryId) {
this.setData({ error: '????ID' })
return
}
this.setData({ loading: true, error: '' })
wx.request({
url: `${app.globalData.baseUrl}/api/v1/entries/${this.entryId}`,
header: { 'X-OpenID': app.globalData.openid },
success: (res) => {
const entry = res.data?.data || null
this.setData({ entry, error: entry ? '' : '?????' })
},
fail: () => this.setData({ error: '??????' }),
complete: () => this.setData({ loading: false }),
})
},
editEntry() {
if (!this.data.entry?.id) return
wx.navigateTo({ url: `/pages/manage/entry-edit/entry-edit?id=${this.data.entry.id}` })
},
deleteEntry() {
if (!this.data.entry?.id) return
wx.showModal({
title: '????',
content: '?????????????',
success: (res) => {
if (!res.confirm) return
wx.request({
url: `${app.globalData.baseUrl}/api/v1/entries/${this.data.entry.id}`,
method: 'DELETE',
header: { 'X-OpenID': app.globalData.openid },
success: () => {
wx.showToast({ title: '???', icon: 'success' })
const pages = getCurrentPages()
const prevPage = pages[pages.length - 2]
if (prevPage && typeof prevPage.loadList === 'function') {
prevPage.loadList()
}
wx.navigateBack({ delta: 1 })
},
})
},
})
},
goBack() {
const pages = getCurrentPages()
const prevPage = pages[pages.length - 2]
if (prevPage && typeof prevPage.loadList === 'function') {
prevPage.loadList()
}
wx.navigateBack()
},
})

View File

@ -0,0 +1,3 @@
{
"usingComponents": {}
}

View File

@ -0,0 +1,60 @@
<view class="page">
<view class="bg-orb orb1"></view>
<view class="bg-orb orb2"></view>
<scroll-view scroll-y class="content">
<view class="hero-card {{entry && entry.entry_type === 'sentence' ? 'pink' : 'cyan'}}" wx:if="{{entry}}">
<view class="hero-top">
<view class="hero-title-group">
<view class="type-chip">{{entry.entry_type === 'sentence' ? '句子' : '单词'}}</view>
<view class="hero-actions-vertical">
<view class="mini-action" bindtap="editEntry" catchtap="editEntry">✎</view>
<view class="mini-action danger" bindtap="deleteEntry" catchtap="deleteEntry">🗑</view>
</view>
</view>
</view>
<text class="main-text">{{entry.en_text}}</text>
<text class="translate">{{entry.entry_type === 'sentence' ? '翻译:' : '释义:'}}{{entry.zh_text}}</text>
<view class="detail-badges">
<view class="badge-pill">来源:{{entry.source_type}}</view>
<view class="badge-pill">状态:{{entry.status}}</view>
</view>
</view>
<view class="header">
<text class="title">词条详情</text>
<text class="subtitle">查看单词或句子的完整信息</text>
</view>
<view wx:if="{{loading}}" class="empty-state">
<text class="empty-title">加载中...</text>
</view>
<view wx:elif="{{error}}" class="error-banner">
<text class="error-text">{{error}}</text>
</view>
<view wx:elif="{{entry}}" class="info-card">
<view class="info-row">
<text class="info-label">类型</text>
<text class="info-value">{{entry.entry_type === 'sentence' ? '句子' : '单词'}}</text>
</view>
<view class="info-row">
<text class="info-label">内容</text>
<text class="info-value">{{entry.en_text}}</text>
</view>
<view class="info-row">
<text class="info-label">{{entry.entry_type === 'sentence' ? '翻译' : '释义'}}</text>
<text class="info-value">{{entry.zh_text}}</text>
</view>
<view class="info-row" wx:if="{{entry.example_text}}">
<text class="info-label">{{entry.entry_type === 'word' ? '例句' : '补充说明'}}</text>
<text class="info-value">{{entry.example_text}}</text>
</view>
<view class="info-row">
<text class="info-label">来源</text>
<text class="info-value">{{entry.source_type}}</text>
</view>
</view>
</scroll-view>
</view>

View File

@ -0,0 +1,241 @@
page {
background: #0c0e12;
color: #f6f6fc;
}
.page {
min-height: 100vh;
background: #0c0e12;
position: relative;
overflow: hidden;
box-sizing: border-box;
}
.bg-orb {
position: absolute;
border-radius: 9999rpx;
filter: blur(110rpx);
pointer-events: none;
z-index: 0;
}
.orb1 {
width: 46vw;
height: 46vw;
top: -14vw;
right: -14vw;
background: rgba(193, 156, 255, 0.12);
}
.orb2 {
width: 54vw;
height: 54vw;
left: -18vw;
bottom: -16vw;
background: rgba(0, 227, 253, 0.08);
}
.content {
position: relative;
z-index: 1;
height: 100vh;
padding: 26rpx 32rpx 40rpx;
box-sizing: border-box;
}
.hero-card {
position: relative;
overflow: hidden;
border-radius: 34rpx;
padding: 34rpx;
border: 1rpx solid rgba(255, 255, 255, 0.08);
background: rgba(29, 32, 37, 0.72);
box-shadow: 0 18rpx 60rpx rgba(0, 0, 0, 0.18);
margin-bottom: 28rpx;
}
.hero-card::after {
content: '';
position: absolute;
inset: auto -40rpx -40rpx auto;
width: 180rpx;
height: 180rpx;
border-radius: 9999rpx;
filter: blur(24rpx);
opacity: 0.4;
}
.hero-card.cyan::after {
background: rgba(0, 227, 253, 0.45);
}
.hero-card.pink::after {
background: rgba(255, 108, 149, 0.45);
}
.hero-top {
position: relative;
z-index: 1;
display: flex;
justify-content: flex-end;
margin-bottom: 24rpx;
}
.hero-title-group {
display: flex;
align-items: flex-start;
gap: 16rpx;
}
.type-chip {
display: inline-flex;
align-items: center;
justify-content: center;
height: 60rpx;
padding: 0 22rpx;
border-radius: 9999rpx;
font-size: 22rpx;
font-weight: 700;
color: #00e3fd;
border: 1rpx solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
}
.hero-actions-vertical {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.mini-action {
width: 52rpx;
height: 52rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 26rpx;
background: rgba(255, 255, 255, 0.06);
border: 1rpx solid rgba(255, 255, 255, 0.08);
color: #f6f6fc;
}
.mini-action.danger {
color: #ff7b9b;
}
.main-text {
position: relative;
z-index: 1;
display: block;
font-size: 46rpx;
line-height: 1.25;
font-weight: 800;
letter-spacing: -0.5rpx;
}
.translate {
position: relative;
z-index: 1;
display: block;
margin-top: 16rpx;
color: #f6f6fc;
font-size: 28rpx;
line-height: 1.7;
}
.detail-badges {
position: relative;
z-index: 1;
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 20rpx;
}
.badge-pill {
padding: 12rpx 18rpx;
border-radius: 9999rpx;
font-size: 22rpx;
color: #aaabb0;
border: 1rpx solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.03);
}
.header {
margin-bottom: 24rpx;
}
.title {
display: block;
font-size: 52rpx;
line-height: 1.1;
font-weight: 800;
}
.subtitle {
display: block;
margin-top: 10rpx;
color: #aaabb0;
font-size: 24rpx;
}
.info-card {
padding: 28rpx;
border-radius: 30rpx;
background: rgba(255, 255, 255, 0.04);
border: 1rpx solid rgba(255, 255, 255, 0.08);
margin-bottom: 26rpx;
}
.info-row {
display: flex;
gap: 18rpx;
padding: 18rpx 0;
border-bottom: 1rpx solid rgba(255, 255, 255, 0.06);
}
.info-row:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.info-label {
width: 120rpx;
flex-shrink: 0;
font-size: 24rpx;
color: #aaabb0;
font-weight: 700;
}
.info-value {
flex: 1;
font-size: 26rpx;
color: #f6f6fc;
line-height: 1.6;
}
.empty-state {
padding: 60rpx 20rpx;
text-align: center;
color: #aaabb0;
}
.empty-title {
display: block;
font-size: 28rpx;
font-weight: 700;
color: #f6f6fc;
}
.error-banner {
padding: 24rpx 28rpx;
border-radius: 20rpx;
background: rgba(167, 1, 56, 0.2);
border: 1rpx solid rgba(255, 110, 132, 0.35);
}
.error-text {
color: #ffb2b9;
font-size: 24rpx;
}

View File

@ -0,0 +1,116 @@
const app = getApp()
Page({
data: {
entryId: null,
entryType: 'word',
enText: '',
zhText: '',
exampleText: '',
loading: false,
error: '',
isEditMode: false,
},
onLoad(options) {
const entryId = options.id ? String(options.id) : null
this.setData({ entryId, isEditMode: !!entryId })
if (entryId) this.loadEntry(entryId)
},
loadEntry(entryId) {
if (!app.globalData.openid) {
this.setData({ error: '请先登录' })
return
}
this.setData({ loading: true, error: '' })
wx.request({
url: `${app.globalData.baseUrl}/api/v1/entries/${entryId}`,
header: { 'X-OpenID': app.globalData.openid },
success: (res) => {
const entry = res.data?.data
if (!entry) {
this.setData({ error: '词条不存在' })
return
}
this.setData({
entryType: entry.entry_type || 'word',
enText: entry.en_text || '',
zhText: entry.zh_text || '',
exampleText: entry.example_text || '',
})
},
fail: () => this.setData({ error: '词条加载失败' }),
complete: () => this.setData({ loading: false }),
})
},
switchType(e) {
const entryType = e.currentTarget.dataset.type
if (this.data.isEditMode && entryType !== this.data.entryType) {
wx.showModal({
title: '切换类型',
content: '切换类型后,当前内容仍会保留,但字段含义会变成新的类型。是否继续?',
confirmText: '继续切换',
cancelText: '取消',
success: (res) => {
if (res.confirm) this.setData({ entryType })
},
})
return
}
this.setData({ entryType })
},
onEnInput(e) {
this.setData({ enText: e.detail.value })
},
onZhInput(e) {
this.setData({ zhText: e.detail.value })
},
onExampleInput(e) {
this.setData({ exampleText: e.detail.value })
},
goBack() {
wx.navigateBack()
},
save() {
if (!app.globalData.openid) {
wx.showToast({ title: '请先登录', icon: 'none' })
return
}
const method = this.data.isEditMode ? 'PUT' : 'POST'
const url = this.data.isEditMode
? `${app.globalData.baseUrl}/api/v1/entries/${this.data.entryId}`
: `${app.globalData.baseUrl}/api/v1/entries`
this.setData({ loading: true, error: '' })
wx.request({
url,
method,
header: { 'Content-Type': 'application/json', 'X-OpenID': app.globalData.openid },
data: this.data.isEditMode
? {
entry_type: this.data.entryType,
en_text: this.data.enText,
zh_text: this.data.zhText,
example_text: this.data.exampleText,
}
: {
entry_type: this.data.entryType,
en_text: this.data.enText,
zh_text: this.data.zhText,
example_text: this.data.exampleText,
source_type: 'manual',
upload_date: new Date().toISOString().slice(0, 10),
},
success: () => {
wx.showToast({ title: this.data.isEditMode ? '更新成功' : '保存成功', icon: 'success' })
if (!this.data.isEditMode) {
this.setData({
enText: '',
zhText: '',
exampleText: '',
})
}
},
fail: () => this.setData({ error: this.data.isEditMode ? '更新失败' : '保存失败' }),
complete: () => this.setData({ loading: false }),
})
},
})

View File

@ -0,0 +1,3 @@
{
"usingComponents": {}
}

View File

@ -0,0 +1,65 @@
<view class="page">
<view class="bg-orb orb1"></view>
<view class="bg-orb orb2"></view>
<view class="topbar">
<view class="back-chip" bindtap="goBack">←</view>
<view class="mode-chip {{isEditMode ? 'mode-chip-edit' : 'mode-chip-add'}}">{{isEditMode ? '编辑模式' : '新增模式'}}</view>
</view>
<scroll-view scroll-y class="content">
<view class="hero-card {{entryType === 'sentence' ? 'hero-pink' : 'hero-cyan'}}">
<view class="hero-copy">
<text class="eyebrow">{{isEditMode ? '更新词条' : '创建词条'}}</text>
<text class="title">{{isEditMode ? '编辑内容' : '添加新内容'}}</text>
<text class="subtitle">{{entryType === 'word' ? '记录单词、释义和例句' : '记录句子、翻译和语境说明'}}</text>
</view>
<view class="hero-badge">{{entryType === 'sentence' ? '句' : '词'}}</view>
</view>
<view class="segment-shell">
<view class="segmented">
<view class="seg {{entryType === 'word' ? 'active' : ''}}" data-type="word" bindtap="switchType">单词</view>
<view class="seg {{entryType === 'sentence' ? 'active' : ''}}" data-type="sentence" bindtap="switchType">句子</view>
</view>
</view>
<view class="tip-card" wx:if="{{isEditMode}}">
<text class="tip-title">编辑提示</text>
<text class="tip-text">切换类型会改变字段含义,但不会自动清空你输入的内容。</text>
</view>
<view class="form">
<view class="field">
<text class="label">{{entryType === 'word' ? '单词' : '句子'}}</text>
<view class="input-shell">
<input
class="input"
placeholder="{{entryType === 'word' ? '输入单词...' : '输入完整句子...'}}"
bindinput="onEnInput"
value="{{enText}}"
/>
</view>
</view>
<view class="field">
<text class="label">{{entryType === 'word' ? '中文释义' : '中文翻译'}}</text>
<view class="input-shell">
<input class="input small" placeholder="{{entryType === 'word' ? '输入对应的中文含义...' : '输入句子的中文翻译...'}}" bindinput="onZhInput" value="{{zhText}}" />
</view>
</view>
<view class="field" wx:if="{{entryType === 'word'}}">
<text class="label">例句 <text class="muted">(可选)</text></text>
<view class="input-shell textarea-shell"><textarea class="textarea" placeholder="添加包含该词的典型例句..." bindinput="onExampleInput" value="{{exampleText}}" /></view>
</view>
<view class="field" wx:else>
<text class="label">补充说明 <text class="muted">(可选)</text></text>
<view class="input-shell textarea-shell"><textarea class="textarea" placeholder="可填写语境、来源或记忆提示..." bindinput="onExampleInput" value="{{exampleText}}" /></view>
</view>
</view>
<view class="action-bar">
<button class="cancel-btn" bindtap="goBack">取消</button>
<button class="save-btn" bindtap="save">{{isEditMode ? '更新' : '保存'}}</button>
</view>
</scroll-view>
</view>

View File

@ -0,0 +1,331 @@
page {
background: #0c0e12;
color: #f6f6fc;
}
.page {
min-height: 100vh;
background: #0c0e12;
position: relative;
overflow: hidden;
box-sizing: border-box;
}
.bg-orb {
position: absolute;
border-radius: 9999rpx;
filter: blur(110rpx);
pointer-events: none;
z-index: 0;
}
.orb1 {
width: 48vw;
height: 48vw;
top: -12vw;
right: -14vw;
background: rgba(193, 156, 255, 0.12);
}
.orb2 {
width: 56vw;
height: 56vw;
left: -18vw;
bottom: -16vw;
background: rgba(0, 227, 253, 0.08);
}
.topbar {
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 32rpx 8rpx;
}
.back-chip,
.mode-chip {
height: 68rpx;
padding: 0 24rpx;
border-radius: 9999rpx;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
font-weight: 700;
border: 1rpx solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
}
.back-chip {
width: 68rpx;
padding: 0;
font-size: 30rpx;
}
.mode-chip-add {
color: #00e3fd;
}
.mode-chip-edit {
color: #c19cff;
}
.content {
position: relative;
z-index: 1;
height: calc(100vh - 120rpx);
padding: 16rpx 32rpx 40rpx;
box-sizing: border-box;
}
.hero-card {
position: relative;
overflow: hidden;
border-radius: 34rpx;
padding: 34rpx;
border: 1rpx solid rgba(255, 255, 255, 0.08);
background: rgba(29, 32, 37, 0.72);
box-shadow: 0 18rpx 60rpx rgba(0, 0, 0, 0.18);
margin-bottom: 28rpx;
}
.hero-card::after {
content: '';
position: absolute;
inset: auto -40rpx -40rpx auto;
width: 180rpx;
height: 180rpx;
border-radius: 9999rpx;
filter: blur(24rpx);
opacity: 0.4;
}
.hero-cyan::after {
background: rgba(0, 227, 253, 0.45);
}
.hero-pink::after {
background: rgba(255, 108, 149, 0.45);
}
.hero-copy {
position: relative;
z-index: 1;
max-width: 520rpx;
}
.eyebrow {
display: block;
color: #aaabb0;
font-size: 22rpx;
letter-spacing: 2rpx;
margin-bottom: 8rpx;
}
.title {
display: block;
font-size: 54rpx;
line-height: 1.1;
font-weight: 800;
}
.subtitle {
display: block;
margin-top: 12rpx;
color: #aaabb0;
font-size: 25rpx;
line-height: 1.6;
}
.hero-badge {
position: absolute;
right: 30rpx;
top: 30rpx;
z-index: 1;
width: 84rpx;
height: 84rpx;
border-radius: 9999rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
font-weight: 800;
color: #f6f6fc;
background: rgba(255, 255, 255, 0.06);
border: 1rpx solid rgba(255, 255, 255, 0.08);
}
.segment-shell {
display: flex;
justify-content: flex-start;
margin-bottom: 18rpx;
}
.segmented {
display: flex;
width: fit-content;
padding: 8rpx;
background: rgba(23, 26, 31, 0.85);
border: 1rpx solid rgba(255, 255, 255, 0.08);
border-radius: 9999rpx;
box-shadow: 0 8rpx 30rpx rgba(0, 0, 0, 0.14);
}
.seg {
padding: 18rpx 34rpx;
border-radius: 9999rpx;
color: #aaabb0;
font-size: 24rpx;
font-weight: 600;
}
.active {
background: #1d2025;
color: #c19cff;
box-shadow: 0 0 24rpx rgba(193, 156, 255, 0.18);
}
.tip-card {
margin-bottom: 26rpx;
padding: 22rpx 26rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.04);
border: 1rpx solid rgba(255, 255, 255, 0.06);
}
.tip-title {
display: block;
font-size: 24rpx;
font-weight: 700;
color: #f6f6fc;
}
.tip-text {
display: block;
margin-top: 8rpx;
color: #aaabb0;
font-size: 22rpx;
line-height: 1.6;
}
.form {
display: flex;
flex-direction: column;
gap: 26rpx;
}
.field {
display: flex;
flex-direction: column;
}
.label {
font-size: 22rpx;
color: #aaabb0;
font-weight: 700;
margin: 0 0 12rpx 4rpx;
}
.muted {
color: rgba(170, 171, 176, 0.55);
font-weight: 400;
}
.input-shell {
min-height: 92rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.04);
border: 1rpx solid rgba(255, 255, 255, 0.08);
box-sizing: border-box;
display: flex;
align-items: center;
backdrop-filter: blur(16rpx);
}
.input-shell:focus-within {
border-color: rgba(193, 156, 255, 0.45);
box-shadow: 0 0 0 1rpx rgba(193, 156, 255, 0.12);
}
.input {
width: 100%;
height: 100%;
min-height: 92rpx;
padding: 0 26rpx;
font-size: 30rpx;
line-height: 92rpx;
color: #f6f6fc;
box-sizing: border-box;
caret-color: #c19cff;
}
.small {
font-size: 28rpx;
line-height: 92rpx;
}
.textarea-shell {
background: rgba(17, 19, 24, 0.95);
align-items: flex-start;
}
.textarea {
width: 100%;
min-height: 220rpx;
padding: 24rpx 26rpx;
font-size: 28rpx;
line-height: 1.6;
color: #f6f6fc;
box-sizing: border-box;
caret-color: #c19cff;
}
.input::placeholder,
.textarea::placeholder {
color: rgba(170, 171, 176, 0.72);
}
.input::-webkit-input-placeholder,
.textarea::-webkit-input-placeholder {
color: rgba(170, 171, 176, 0.72);
}
.input::-moz-placeholder,
.textarea::-moz-placeholder {
color: rgba(170, 171, 176, 0.72);
}
.input:-ms-input-placeholder,
.textarea:-ms-input-placeholder {
color: rgba(170, 171, 176, 0.72);
}
.action-bar {
display: flex;
gap: 18rpx;
padding-top: 28rpx;
margin-top: 36rpx;
}
.cancel-btn,
.save-btn {
flex: 1;
border-radius: 9999rpx;
padding: 22rpx 34rpx;
font-weight: 700;
font-size: 26rpx;
}
.cancel-btn {
background: rgba(255, 255, 255, 0.04);
color: #f6f6fc;
border: 1rpx solid rgba(255, 255, 255, 0.08);
}
.save-btn {
background: linear-gradient(90deg, #c19cff, #9146ff);
color: #fff;
box-shadow: 0 14rpx 30rpx rgba(145, 70, 255, 0.24);
}

18
manage/logs/logs.js Normal file
View File

@ -0,0 +1,18 @@
// logs.js
const util = require('../../utils/util.js')
Page({
data: {
logs: []
},
onLoad() {
this.setData({
logs: (wx.getStorageSync('logs') || []).map(log => {
return {
date: util.formatTime(new Date(log)),
timeStamp: log
}
})
})
}
})

3
manage/logs/logs.json Normal file
View File

@ -0,0 +1,3 @@
{
"navigationBarTitleText": "日志"
}

6
manage/logs/logs.wxml Normal file
View File

@ -0,0 +1,6 @@
<!--logs.wxml-->
<scroll-view class="scrollarea" scroll-y type="list">
<block wx:for="{{logs}}" wx:key="timeStamp" wx:for-item="log">
<view class="log-item">{{index + 1}}. {{log.date}}</view>
</block>
</scroll-view>

16
manage/logs/logs.wxss Normal file
View File

@ -0,0 +1,16 @@
page {
height: 100vh;
display: flex;
flex-direction: column;
}
.scrollarea {
flex: 1;
overflow-y: hidden;
}
.log-item {
margin-top: 20rpx;
text-align: center;
}
.log-item:last-child {
padding-bottom: env(safe-area-inset-bottom);
}

View File

@ -0,0 +1,45 @@
const app = getApp()
Page({
data: {
list: [],
loading: false,
error: '',
},
onShow() {
this.loadList()
},
loadList() {
if (!app.globalData.openid) {
this.setData({ error: '请先登录' })
return
}
this.setData({ loading: true, error: '' })
wx.request({
url: `${app.globalData.baseUrl}/api/v1/entries`,
header: { 'X-OpenID': app.globalData.openid },
success: (res) => {
const payload = res.data?.data || []
this.setData({ list: payload })
},
fail: () => this.setData({ error: '词库加载失败' }),
complete: () => this.setData({ loading: false }),
})
},
goAdd() {
wx.navigateTo({ url: '/pages/manage/entry-edit/entry-edit' })
},
goDetail(e) {
const { id } = e.currentTarget.dataset
wx.navigateTo({ url: `/pages/manage/entry-detail/entry-detail?id=${id}` })
},
goBatch() {
wx.navigateTo({ url: '/pages/manage/batch-upload/batch-upload' })
},
goProgress() {
wx.navigateTo({ url: '/pages/progress/progress-list/progress-list' })
},
goSettings() {
wx.navigateTo({ url: '/pages/settings/settings' })
},
})

View File

@ -0,0 +1,3 @@
{
"usingComponents": {}
}

View File

@ -0,0 +1,55 @@
<view class="page">
<scroll-view scroll-y class="content">
<view class="header">
<text class="title">词库管理</text>
<text class="subtitle">管理并策划你的个人词汇集合。</text>
</view>
<!-- <view class="toolbar">
<button class="primary-btn" bindtap="goAdd">新增</button>
<button class="secondary-btn" bindtap="goBatch">批量上传</button>
<button class="secondary-btn" bindtap="goProgress">进度</button>
<button class="secondary-btn" bindtap="goSettings">设置</button>
</view> -->
<view class="search-row">
<view class="search-box">
<text class="search-icon">🔎</text>
<text class="search-placeholder">搜索单词、短语或标签...</text>
</view>
</view>
<view wx:if="{{loading}}" class="empty-state">
<text class="empty-title">加载中...</text>
<text class="empty-subtitle">正在同步词库数据</text>
</view>
<view wx:elif="{{error}}" class="error-banner">
<text class="error-text">{{error}}</text>
</view>
<view wx:elif="{{!list.length}}" class="empty-state">
<text class="empty-title">暂无词条</text>
<text class="empty-subtitle">点击“新增”或“批量上传”开始构建词库</text>
</view>
<view wx:else class="card-grid">
<block wx:for="{{list}}" wx:key="id">
<view class="word-card {{item.entry_type === 'sentence' ? 'pink' : 'cyan'}}" data-id="{{item.id}}" bindtap="goDetail">
<view class="card-top">
<view>
<text class="word-title">{{item.en_text}}</text>
<text class="phonetic">{{item.entry_type === 'sentence' ? '句子' : '单词'}}</text>
</view>
<view class="status-dot done">{{item.status}}</view>
</view>
<text class="meaning">{{item.entry_type === 'sentence' ? item.zh_text : item.zh_text}}</text>
<text class="example" wx:if="{{item.entry_type === 'word' && item.example_text}}">例句:{{item.example_text}}</text>
<text class="example" wx:elif="{{item.entry_type === 'sentence' && item.example_text}}">说明:{{item.example_text}}</text>
</view>
</block>
</view>
</scroll-view>
<view class="fab" bindtap="goAdd"></view>
</view>

View File

@ -0,0 +1 @@
page{background:#0c0e12;color:#f6f6fc}.page{min-height:100vh;background:#0c0e12;box-sizing:border-box}.topbar{display:flex;justify-content:space-between;align-items:center;padding:24rpx 32rpx;background:rgba(12,14,18,.8);backdrop-filter:blur(24px);border-bottom:1rpx solid rgba(255,255,255,.08);box-shadow:0 1rpx 20rpx rgba(0,0,0,.08)}.brand{display:flex;align-items:center;gap:18rpx}.avatar-wrap{width:64rpx;height:64rpx;border-radius:9999rpx;overflow:hidden;border:1rpx solid rgba(255,255,255,.1);flex-shrink:0}.avatar{width:100%;height:100%}.brand-text{font-size:38rpx;font-weight:800;color:#c19cff}.icon-btn{width:72rpx;height:72rpx;border-radius:9999rpx;background:rgba(255,255,255,.06);display:flex;align-items:center;justify-content:center}.content{height:calc(100vh - 120rpx);padding:40rpx 32rpx 32rpx;box-sizing:border-box}.header{margin-bottom:28rpx}.title{display:block;font-size:52rpx;font-weight:800}.subtitle{display:block;margin-top:10rpx;color:#aaabb0;font-size:26rpx}.search-row{display:flex;flex-direction:column;gap:18rpx;margin-bottom:28rpx}.search-box{display:flex;align-items:center;gap:12rpx;padding:22rpx 26rpx;border-radius:9999rpx;background:#000;border:1rpx solid rgba(255,255,255,.1);color:#aaabb0}.search-placeholder{font-size:24rpx}.filters{display:flex;gap:14rpx;overflow-x:auto}.filter-btn{padding:18rpx 22rpx;border-radius:9999rpx;background:#23262c;color:#f6f6fc;font-size:22rpx;white-space:nowrap}.dim{color:#aaabb0;margin:0 4rpx}.card-grid{display:grid;grid-template-columns:1fr;gap:18rpx}.word-card{position:relative;overflow:hidden;padding:28rpx;border-radius:28rpx;background:rgba(29,32,37,.56);backdrop-filter:blur(24rpx);border:1rpx solid rgba(255,255,255,.06);box-shadow:0 20rpx 40rpx rgba(0,0,0,.06)}.cyan{box-shadow:0 20rpx 40rpx rgba(0,229,255,.05)}.purple{box-shadow:0 20rpx 40rpx rgba(127,0,255,.05)}.pink{box-shadow:0 20rpx 40rpx rgba(255,64,129,.05)}.wide{min-height:280rpx}.card-top{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:18rpx}.word-title{display:block;font-size:38rpx;font-weight:800}.phonetic{display:block;margin-top:8rpx;font-size:22rpx;color:#aaabb0}.status-dot{width:56rpx;height:56rpx;border-radius:9999rpx;display:flex;align-items:center;justify-content:center;background:rgba(255,255,255,.06)}.done{color:#00e5ff}.learn{color:#c19cff}.review{color:#ff4081}.meaning{display:block;font-size:26rpx;color:#f6f6fc;line-height:1.6}.split{display:grid;grid-template-columns:1fr 1fr;gap:18rpx;align-items:start}.example{font-size:22rpx;color:#aaabb0;font-style:italic;border-left:4rpx solid rgba(193,156,255,.3);padding-left:14rpx;line-height:1.6}.progress-block{margin-top:22rpx;padding-top:18rpx;border-top:1rpx solid rgba(255,255,255,.05)}.progress-meta{display:flex;justify-content:space-between;font-size:20rpx;color:#aaabb0;margin-bottom:10rpx}.accent-cyan{color:#00e5ff}.accent-purple{color:#c19cff}.accent-pink{color:#ff4081}.progress-bar{height:10rpx;border-radius:9999rpx;background:#111318;overflow:hidden}.fill{height:100%;border-radius:9999rpx}.cyan-fill{width:95%;background:#00e5ff}.purple-fill{width:60%;background:#c19cff}.pink-fill{width:30%;background:#ff4081}.fab{position:fixed;right:28rpx;bottom:130rpx;width:96rpx;height:96rpx;border-radius:9999rpx;background:linear-gradient(135deg,#c19cff,#00e5ff);display:flex;align-items:center;justify-content:center;color:#fff;font-size:48rpx;box-shadow:0 0 20rpx rgba(193,156,255,.2)}

View File

@ -0,0 +1,172 @@
const app = getApp()
const ACHIEVEMENT_ICON_MAP = {
MASTERED_100: '/assets/icons/achievements/core/100.svg',
MASTERED_1000: '/assets/icons/achievements/core/1000.svg',
MASTERED_3000: '/assets/icons/achievements/core/3000.svg',
MASTERED_5000: '/assets/icons/achievements/core/5000.svg',
MASTERED_7000: '/assets/icons/achievements/core/7000.svg',
MASTERED_10000: '/assets/icons/achievements/core/10000.svg',
TOTAL_100: '/assets/icons/achievements/core/total-100.svg',
CHECKIN_7: '/assets/icons/achievements/core/checkin-30.svg',
CHECKIN_30: '/assets/icons/achievements/core/checkin-30.svg',
CHECKIN_100: '/assets/icons/achievements/core/streak.svg',
CHECKIN_180: '/assets/icons/achievements/core/streak.svg',
CHECKIN_360: '/assets/icons/achievements/core/streak.svg',
CHECKIN_STREAK_7: '/assets/icons/achievements/core/streak.svg',
CHECKIN_STREAK_30: '/assets/icons/achievements/core/streak.svg',
CHECKIN_STREAK_100: '/assets/icons/achievements/core/streak.svg',
CHECKIN_STREAK_180: '/assets/icons/achievements/core/streak.svg',
CHECKIN_STREAK_360: '/assets/icons/achievements/core/streak.svg',
ACCURACY_80: '/assets/icons/achievements/core/accuracy.svg',
ACCURACY_90: '/assets/icons/achievements/core/accuracy.svg',
DAILY_REVIEW_20: '/assets/icons/achievements/core/review-volume.svg',
DAILY_REVIEW_50: '/assets/icons/achievements/core/review-volume.svg',
}
const STATUS_ICON_MAP = {
1: '/assets/icons/achievements/state/achievement-unlocked.svg',
0: '/assets/icons/achievements/state/achievement-locked.svg',
}
const GROUP_ORDER = {
mastery: 1,
checkin: 2,
streak: 3,
accuracy: 4,
review: 5,
other: 9,
}
const GROUP_META = {
mastery: { title: '掌握进阶', subtitle: '从入门到精通', icon: '/assets/icons/achievements/core/mastery-advanced.svg' },
checkin: { title: '打卡里程', subtitle: '记录坚持的脚步', icon: '/assets/icons/achievements/core/checkin-30.svg' },
streak: { title: '连续坚持', subtitle: '不断线的努力', icon: '/assets/icons/achievements/core/streak.svg' },
accuracy: { title: '准确率', subtitle: '复习质量提升', icon: '/assets/icons/achievements/core/accuracy.svg' },
review: { title: '复习数量', subtitle: '积累复习量', icon: '/assets/icons/achievements/core/review-volume.svg' },
other: { title: '其他', subtitle: '更多成就', icon: '/assets/icons/achievements/core/total-100.svg' },
}
const GROUP_PROGRESS = {
mastery: [100, 1000, 3000, 5000, 7000, 10000],
checkin: [7, 30, 100, 180, 360],
streak: [7, 30, 100, 180, 360],
accuracy: [80, 90],
review: [20, 50],
}
function getNextTarget(items, values) {
const unlockedMax = items.filter((item) => item.is_unlocked).reduce((max, item) => Math.max(max, item.target_value || 0), 0)
const nextTarget = values.find((value) => value > unlockedMax)
return nextTarget || null
}
function formatProgressText(key, items) {
const values = GROUP_PROGRESS[key] || []
const nextTarget = getNextTarget(items, values)
const best = items.reduce((max, item) => Math.max(max, item.progress_value || 0), 0)
if (nextTarget) {
return `距离下一档还差 ${Math.max(0, nextTarget - best)}`
}
if (items.some((item) => item.is_unlocked)) {
return '该分组已全部解锁'
}
return '继续坚持,解锁第一档'
}
Page({
data: {
total: 0,
unlocked: 0,
sections: [],
loading: false,
error: '',
},
onShow() {
this.loadData()
},
getGroup(code) {
if (code.startsWith('MASTERED_')) return 'mastery'
if (code.startsWith('CHECKIN_STREAK_')) return 'streak'
if (code.startsWith('CHECKIN_')) return 'checkin'
if (code.startsWith('ACCURACY_')) return 'accuracy'
if (code.startsWith('DAILY_REVIEW_')) return 'review'
return 'other'
},
decorateList(list) {
return (list || []).map((item) => ({
...item,
group: this.getGroup(item.achievement_code),
icon: ACHIEVEMENT_ICON_MAP[item.achievement_code] || GROUP_META[this.getGroup(item.achievement_code)].icon,
stateIcon: STATUS_ICON_MAP[item.is_unlocked] || STATUS_ICON_MAP[0],
}))
},
buildSections(list) {
const buckets = { unlocked: {}, locked: {} }
list.forEach((item) => {
const state = item.is_unlocked ? 'unlocked' : 'locked'
const key = item.group || 'other'
if (!buckets[state][key]) buckets[state][key] = []
buckets[state][key].push(item)
})
const buildStateSections = (state) => Object.keys(buckets[state])
.sort((a, b) => (GROUP_ORDER[a] || 99) - (GROUP_ORDER[b] || 99))
.map((key) => {
const items = buckets[state][key].sort((a, b) => a.target_value - b.target_value)
return {
key: `${state}-${key}`,
state,
meta: GROUP_META[key] || GROUP_META.other,
progressText: formatProgressText(key, list.filter((item) => item.group === key)),
items,
}
})
return [
{ key: 'unlocked', title: '已解锁', sections: buildStateSections('unlocked') },
{ key: 'locked', title: '未解锁', sections: buildStateSections('locked') },
].filter((group) => group.sections.length)
},
loadData() {
this.setData({ loading: true, error: '' })
wx.request({
url: `${app.globalData.baseUrl}/api/v1/achievements`,
header: { 'X-OpenID': app.globalData.openid || '' },
success: (res) => {
const list = this.decorateList(res.data?.data || [])
const sections = this.buildSections(list)
this.setData({
sections,
total: list.length,
unlocked: list.filter((item) => item.is_unlocked).length,
})
},
fail: () => this.setData({ error: '成就数据加载失败' }),
complete: () => this.setData({ loading: false }),
})
},
refreshAchievements() {
this.setData({ loading: true, error: '' })
wx.request({
url: `${app.globalData.baseUrl}/api/v1/achievements/refresh`,
method: 'POST',
header: { 'X-OpenID': app.globalData.openid || '' },
success: (res) => {
const payload = res.data?.data || {}
const list = this.decorateList(payload.achievements || [])
const sections = this.buildSections(list)
this.setData({
sections,
total: list.length,
unlocked: list.filter((item) => item.is_unlocked).length,
})
if ((payload.just_unlocked_count || 0) > 0) {
wx.showToast({ title: `新解锁 ${payload.just_unlocked_count} 项成就`, icon: 'success' })
}
},
fail: () => this.setData({ error: '成就刷新失败' }),
complete: () => this.setData({ loading: false }),
})
},
})

View File

@ -0,0 +1,3 @@
{
"usingComponents": {}
}

View File

@ -0,0 +1,72 @@
<view class="page">
<scroll-view scroll-y class="content">
<view class="heading">
<text class="title">成就墙</text>
<text class="subtitle">把你的每一次坚持都挂在墙上</text>
</view>
<view class="hero-card">
<view class="hero-glow"></view>
<view class="hero-row">
<view>
<text class="hero-num">{{unlocked}}</text>
<text class="hero-label">已点亮勋章 / {{total}}</text>
</view>
<view class="hero-badge">
<image class="hero-badge-img" src="/assets/icons/achievements/state/achievement-unlocked.svg" mode="aspectFit" />
</view>
</view>
</view>
<view wx:if="{{loading}}" class="empty-state">
<text class="empty-title">加载中...</text>
<text class="empty-subtitle">正在同步成就数据</text>
</view>
<view wx:elif="{{error}}" class="error-banner">
<text class="error-text">{{error}}</text>
</view>
<view wx:elif="{{!sections.length}}" class="empty-state">
<text class="empty-title">暂无成就</text>
<text class="empty-subtitle">继续复习和打卡后会逐渐解锁</text>
</view>
<view wx:else>
<block wx:for="{{sections}}" wx:key="key">
<view class="state-card {{item.key}}">
<view class="state-head">
<text class="state-title">{{item.title}}</text>
<text class="state-subtitle">{{item.sections.length}} 个分组</text>
</view>
<block wx:for="{{item.sections}}" wx:key="key">
<view class="section-card">
<view class="section-head">
<view class="section-title-wrap">
<image class="section-icon" src="{{item.meta.icon}}" mode="aspectFit" />
<view>
<text class="section-title">{{item.meta.title}}</text>
<text class="section-subtitle">{{item.meta.subtitle}}</text>
</view>
</view>
<text class="section-count">{{item.items.length}} 项</text>
</view>
<text class="section-progress">{{item.progressText}}</text>
<view class="grid">
<block wx:for="{{item.items}}" wx:key="id">
<view class="item {{item.is_unlocked ? 'accent-primary' : 'accent-secondary'}}">
<view class="item-icon">
<image class="item-icon-img" src="{{item.icon}}" mode="aspectFit" />
</view>
<text class="item-title">{{item.achievement_name}}</text>
<text class="item-sub">{{item.progress_value}} / {{item.target_value}}</text>
</view>
</block>
</view>
</view>
</block>
</view>
</block>
</view>
</scroll-view>
</view>

View File

@ -0,0 +1,287 @@
page {
background: #0c0e12;
color: #f6f6fc
}
.page {
min-height: 100vh;
background: #0c0e12;
box-sizing: border-box
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 32rpx;
background: rgba(12, 14, 18, .8);
backdrop-filter: blur(24px);
border-bottom: 1rpx solid rgba(255, 255, 255, .08);
box-shadow: 0 1rpx 20rpx rgba(0, 0, 0, .08)
}
.brand {
display: flex;
align-items: center;
gap: 18rpx
}
.avatar-wrap {
width: 64rpx;
height: 64rpx;
border-radius: 9999rpx;
overflow: hidden;
border: 1rpx solid rgba(255, 255, 255, .1);
flex-shrink: 0
}
.avatar {
width: 100%;
height: 100%
}
.brand-text {
font-size: 38rpx;
font-weight: 800;
color: #c19cff
}
.icon-btn {
width: 72rpx;
height: 72rpx;
border-radius: 9999rpx;
background: rgba(255, 255, 255, .06);
display: flex;
align-items: center;
justify-content: center
}
.content {
height: calc(100vh - 120rpx);
padding: 48rpx 40rpx 32rpx;
box-sizing: border-box
}
.heading {
margin-bottom: 28rpx
}
.title {
display: block;
font-size: 52rpx;
font-weight: 800;
line-height: 1.1
}
.subtitle {
display: block;
margin-top: 10rpx;
color: #aaabb0;
font-size: 26rpx
}
.hero-card {
position: relative;
overflow: hidden;
padding: 32rpx;
border-radius: 32rpx;
background: rgba(29, 32, 37, .6);
backdrop-filter: blur(24rpx);
border: 1rpx solid rgba(255, 255, 255, .08);
box-shadow: 0 20rpx 40rpx rgba(0, 0, 0, .08);
margin-bottom: 24rpx
}
.hero-glow {
position: absolute;
right: -40rpx;
top: -40rpx;
width: 160rpx;
height: 160rpx;
border-radius: 9999rpx;
background: rgba(255, 64, 129, .12);
filter: blur(30rpx)
}
.hero-row {
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
z-index: 1
}
.hero-num {
display: block;
font-size: 72rpx;
font-weight: 800;
color: #fff
}
.hero-label {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
color: #aaabb0
}
.hero-badge {
width: 112rpx;
height: 112rpx;
border-radius: 9999rpx;
background: linear-gradient(135deg, #ff4081, #00e5ff);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 20rpx rgba(255, 64, 129, .2);
overflow: hidden
}
.hero-badge-img {
width: 64rpx;
height: 64rpx
}
.state-card {
margin-bottom: 28rpx
}
.state-head {
display: flex;
justify-content: space-between;
align-items: center;
margin: 0 6rpx 18rpx
}
.state-title {
font-size: 36rpx;
font-weight: 800
}
.state-subtitle {
font-size: 22rpx;
color: #aaabb0
}
.section-card {
margin-bottom: 20rpx;
padding: 28rpx;
border-radius: 30rpx;
background: rgba(29, 32, 37, .45);
border: 1rpx solid rgba(255, 255, 255, .07);
backdrop-filter: blur(24rpx)
}
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14rpx
}
.section-title-wrap {
display: flex;
align-items: center;
gap: 16rpx
}
.section-icon {
width: 52rpx;
height: 52rpx;
border-radius: 9999rpx;
background: rgba(255, 255, 255, .06);
padding: 8rpx;
box-sizing: border-box
}
.section-title {
display: block;
font-size: 32rpx;
font-weight: 800;
line-height: 1.2
}
.section-subtitle {
display: block;
margin-top: 4rpx;
font-size: 22rpx;
color: #aaabb0
}
.section-count {
font-size: 22rpx;
color: #c19cff;
background: rgba(193, 156, 255, .12);
padding: 8rpx 14rpx;
border-radius: 9999rpx
}
.section-progress {
display: block;
margin: 0 0 18rpx 68rpx;
font-size: 22rpx;
color: #8fe3ff
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18rpx
}
.item {
position: relative;
overflow: hidden;
padding: 28rpx;
border-radius: 28rpx;
background: rgba(29, 32, 37, .56);
backdrop-filter: blur(24rpx);
border: 1rpx solid rgba(255, 255, 255, .06);
box-shadow: 0 20rpx 40rpx rgba(0, 0, 0, .06);
min-height: 220rpx
}
.accent-tertiary {
box-shadow: 0 20rpx 40rpx rgba(255, 64, 129, .06)
}
.accent-secondary {
box-shadow: 0 20rpx 40rpx rgba(0, 229, 255, .06)
}
.accent-primary {
box-shadow: 0 20rpx 40rpx rgba(127, 0, 255, .06)
}
.item-icon {
width: 130rpx;
height: 130rpx;
border-radius: 9999rpx;
background: rgba(255, 255, 255, .06);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 24rpx;
overflow: hidden
}
.item-icon-img {
width: 110rpx;
height: 110rpx;
flex-shrink: 0
}
.item-title {
display: block;
font-size: 30rpx;
font-weight: 700;
margin-bottom: 8rpx;
text-align: center
}
.item-sub {
display: block;
font-size: 22rpx;
color: #aaabb0;
text-align: center
}

View File

@ -0,0 +1,22 @@
const app = getApp()
Page({
data: {
stats: null,
loading: false,
error: '',
},
onShow() {
this.loadStats()
},
loadStats() {
this.setData({ loading: true, error: '' })
wx.request({
url: `${app.globalData.baseUrl}/api/v1/checkin/stats`,
header: { 'X-OpenID': app.globalData.openid || '' },
success: (res) => this.setData({ stats: res.data?.data || null }),
fail: () => this.setData({ error: '打卡数据加载失败' }),
complete: () => this.setData({ loading: false }),
})
},
})

View File

@ -0,0 +1,3 @@
{
"usingComponents": {}
}

View File

@ -0,0 +1,42 @@
<view class="page">
<scroll-view scroll-y class="content">
<view class="header">
<text class="title">打卡详情</text>
<text class="subtitle">查看近期的学习与坚持记录。</text>
</view>
<view wx:if="{{!stats}}" class="empty-state">
<text class="empty-title">暂无打卡数据</text>
<text class="empty-subtitle">完成一次复习后即可自动生成打卡记录</text>
</view>
<view class="card hero" wx:if="{{stats}}">
<view class="hero-row">
<view>
<text class="hero-num">{{stats.streak_days}}</text>
<text class="hero-label">连续打卡天数</text>
</view>
<view class="fire">🔥</view>
</view>
<view class="progress-bar"><view class="fill" style="width: {{stats.week_checkins / 7 * 100}}%;"></view></view>
<view class="week-row">
<text class="day">一</text><text class="day">二</text><text class="day">三</text><text class="day">四</text><text class="day active">五</text><text class="day">六</text><text class="day">日</text>
</view>
</view>
<view class="timeline" wx:if="{{stats}}">
<view class="entry">
<view class="dot done"></view>
<view class="entry-card"><text class="entry-title">总打卡次数</text><text class="entry-desc">{{stats.total_checkins}} 次</text></view>
</view>
<view class="entry">
<view class="dot"></view>
<view class="entry-card low"><text class="entry-title">本周打卡</text><text class="entry-desc">{{stats.week_checkins}} 次</text></view>
</view>
<view class="entry">
<view class="dot"></view>
<view class="entry-card low"><text class="entry-title">本月打卡</text><text class="entry-desc">{{stats.month_checkins}} 次</text></view>
</view>
</view>
</scroll-view>
</view>

View File

@ -0,0 +1 @@
page{background:#0c0e12;color:#f6f6fc}.page{min-height:100vh;background:#0c0e12;box-sizing:border-box}.topbar{display:flex;justify-content:space-between;align-items:center;padding:24rpx 32rpx;background:rgba(12,14,18,.8);backdrop-filter:blur(24px);border-bottom:1rpx solid rgba(255,255,255,.08);box-shadow:0 1rpx 20rpx rgba(0,0,0,.08)}.brand{display:flex;align-items:center;gap:18rpx}.avatar-wrap{width:64rpx;height:64rpx;border-radius:9999rpx;overflow:hidden;border:1rpx solid rgba(255,255,255,.1);flex-shrink:0}.avatar{width:100%;height:100%}.brand-text{font-size:38rpx;font-weight:800;color:#c19cff}.icon-btn{width:72rpx;height:72rpx;border-radius:9999rpx;background:rgba(255,255,255,.06);display:flex;align-items:center;justify-content:center}.content{height:calc(100vh - 120rpx);padding:40rpx 32rpx;box-sizing:border-box}.header{margin-bottom:24rpx}.title{display:block;font-size:52rpx;font-weight:800}.subtitle{display:block;margin-top:10rpx;color:#aaabb0;font-size:24rpx}.card{background:rgba(29,32,37,.56);border:1rpx solid rgba(255,255,255,.08);border-radius:28rpx}.hero{padding:28rpx}.hero-row{display:flex;justify-content:space-between;align-items:center}.hero-num{display:block;font-size:72rpx;font-weight:800}.hero-label{display:block;margin-top:6rpx;color:#aaabb0;font-size:22rpx}.fire{width:76rpx;height:76rpx;border-radius:9999rpx;background:rgba(255,255,255,.06);display:flex;align-items:center;justify-content:center;font-size:34rpx}.progress-bar{height:10rpx;margin-top:20rpx;border-radius:9999rpx;background:#23262c;overflow:hidden}.fill{width:71%;height:100%;background:linear-gradient(90deg,#c19cff,#00e5ff)}.week-row{display:flex;justify-content:space-between;margin-top:16rpx;color:#aaabb0;font-size:22rpx}.day.active{color:#00e5ff;font-weight:700}.timeline{margin-top:18rpx;display:flex;flex-direction:column;gap:14rpx}.entry{display:flex;gap:14rpx;align-items:stretch}.dot{width:18rpx;height:18rpx;margin-top:20rpx;border-radius:9999rpx;background:#23262c;flex-shrink:0}.dot.done{background:#00e5ff;box-shadow:0 0 12rpx rgba(0,229,255,.6)}.entry-card{flex:1;padding:20rpx;border-radius:22rpx;background:#171a1f;border:1rpx solid rgba(255,255,255,.06)}.low{background:#111318}.entry-title{display:block;font-size:26rpx;font-weight:700}.entry-desc{display:block;margin-top:6rpx;color:#aaabb0;font-size:22rpx}

View File

@ -0,0 +1,54 @@
const app = getApp()
const groupByType = (list) => ({
word: list.filter((item) => item.entry_type === 'word'),
sentence: list.filter((item) => item.entry_type === 'sentence'),
})
Page({
data: {
reviewList: [],
masteredList: [],
reviewWords: [],
reviewSentences: [],
masteredWords: [],
masteredSentences: [],
reviewWordCollapsed: false,
reviewSentenceCollapsed: false,
masteredWordCollapsed: false,
masteredSentenceCollapsed: false,
loading: false,
error: '',
},
onShow() {
this.loadData()
},
toggleSection(e) {
const key = e.currentTarget.dataset.key
this.setData({ [key]: !this.data[key] })
},
loadData() {
this.setData({ loading: true, error: '' })
wx.request({
url: `${app.globalData.baseUrl}/api/v1/progress/review`,
header: { 'X-OpenID': app.globalData.openid || '' },
success: (res) => {
const reviewList = res.data?.data || []
const grouped = groupByType(reviewList)
this.setData({ reviewList, reviewWords: grouped.word, reviewSentences: grouped.sentence })
},
fail: () => this.setData({ error: '待复习数据加载失败' }),
})
wx.request({
url: `${app.globalData.baseUrl}/api/v1/progress/mastered`,
header: { 'X-OpenID': app.globalData.openid || '' },
success: (res) => {
const masteredList = res.data?.data || []
const grouped = groupByType(masteredList)
this.setData({ masteredList, masteredWords: grouped.word, masteredSentences: grouped.sentence })
},
fail: () => this.setData({ error: '已掌握数据加载失败' }),
complete: () => this.setData({ loading: false }),
})
},
})

View File

@ -0,0 +1,3 @@
{
"usingComponents": {}
}

View File

@ -0,0 +1,167 @@
<view class="page">
<view class="bg-orb bg-orb-right"></view>
<view class="bg-orb bg-orb-left"></view>
<scroll-view scroll-y class="content">
<view class="header">
<text class="title">复习档案</text>
<text class="subtitle">您的词汇神经元网络正在构建中。</text>
</view>
<view class="tabs">
<view class="tab tab-active">
<text class="tab-label">待复习</text>
</view>
<view class="tab">
<text class="tab-label">已掌握</text>
</view>
</view>
<view wx:if="{{loading}}" class="empty-state">
<text class="empty-title">加载中...</text>
<text class="empty-subtitle">正在同步进度数据</text>
</view>
<view wx:elif="{{error}}" class="error-banner">
<text class="error-text">{{error}}</text>
</view>
<view wx:else>
<view class="section">
<view class="section-head" bindtap="toggleSection" data-key="reviewWordCollapsed">
<view class="section-title-wrap">
<text class="section-pill section-pill-pink">待复习</text>
<text class="section-title">单词</text>
</view>
<view class="section-toggle-chip {{reviewWordCollapsed ? 'collapsed' : 'expanded'}}">
<text class="section-toggle-text">{{reviewWordCollapsed ? '展开' : '收起'}}</text>
</view>
</view>
<view wx:if="{{!reviewWordCollapsed}}" class="grid">
<block wx:for="{{reviewWords}}" wx:key="id">
<view class="card card-pink">
<view class="card-glow card-glow-pink"></view>
<view class="card-head">
<view>
<text class="word word-large">{{item.en_text}}</text>
<text class="desc desc-spaced">{{item.zh_text}}</text>
</view>
<view class="badge badge-pink">🔥</view>
</view>
<view class="progress-meta">
<text class="progress-label">神经元连接强度</text>
<text class="progress-value progress-pink">{{item.mastery_score}}%</text>
</view>
<view class="progress-track">
<view class="progress-fill progress-fill-pink" style="width: {{item.mastery_score}}%;"></view>
</view>
</view>
</block>
</view>
</view>
<view class="section">
<view class="section-head" bindtap="toggleSection" data-key="reviewSentenceCollapsed">
<view class="section-title-wrap">
<text class="section-pill section-pill-cyan">待复习</text>
<text class="section-title">句子</text>
</view>
<view class="section-toggle-chip {{reviewSentenceCollapsed ? 'collapsed' : 'expanded'}}">
<text class="section-toggle-text">{{reviewSentenceCollapsed ? '展开' : '收起'}}</text>
</view>
</view>
<view wx:if="{{!reviewSentenceCollapsed}}" class="grid">
<block wx:for="{{reviewSentences}}" wx:key="id">
<view class="card card-cyan">
<view class="card-glow card-glow-cyan"></view>
<view class="card-head">
<view>
<text class="word word-large">{{item.en_text}}</text>
<text class="desc desc-spaced">{{item.zh_text}}</text>
</view>
<view class="badge badge-cyan">📝</view>
</view>
<view class="progress-meta">
<text class="progress-label">神经元连接强度</text>
<text class="progress-value progress-cyan">{{item.mastery_score}}%</text>
</view>
<view class="progress-track">
<view class="progress-fill progress-fill-cyan" style="width: {{item.mastery_score}}%;"></view>
</view>
</view>
</block>
</view>
</view>
<view class="section">
<view class="section-head" bindtap="toggleSection" data-key="masteredWordCollapsed">
<view class="section-title-wrap">
<text class="section-pill section-pill-cyan">已掌握</text>
<text class="section-title">单词</text>
</view>
<view class="section-toggle-chip {{masteredWordCollapsed ? 'collapsed' : 'expanded'}}">
<text class="section-toggle-text">{{masteredWordCollapsed ? '展开' : '收起'}}</text>
</view>
</view>
<view wx:if="{{!masteredWordCollapsed}}" class="grid">
<block wx:for="{{masteredWords}}" wx:key="id">
<view class="card card-cyan">
<view class="card-glow card-glow-cyan"></view>
<view class="card-head">
<view>
<text class="word word-large">{{item.en_text}}</text>
<text class="desc desc-spaced">{{item.zh_text}}</text>
</view>
<view class="badge badge-cyan">✓</view>
</view>
<view class="progress-meta">
<text class="progress-label">神经元连接强度</text>
<text class="progress-value progress-cyan">{{item.mastery_score}}%</text>
</view>
<view class="progress-track">
<view class="progress-fill progress-fill-cyan" style="width: {{item.mastery_score}}%;"></view>
</view>
</view>
</block>
</view>
</view>
<view class="section">
<view class="section-head" bindtap="toggleSection" data-key="masteredSentenceCollapsed">
<view class="section-title-wrap">
<text class="section-pill section-pill-pink">已掌握</text>
<text class="section-title">句子</text>
</view>
<view class="section-toggle-chip {{masteredSentenceCollapsed ? 'collapsed' : 'expanded'}}">
<text class="section-toggle-text">{{masteredSentenceCollapsed ? '展开' : '收起'}}</text>
</view>
</view>
<view wx:if="{{!masteredSentenceCollapsed}}" class="grid">
<block wx:for="{{masteredSentences}}" wx:key="id">
<view class="card card-pink">
<view class="card-glow card-glow-pink"></view>
<view class="card-head">
<view>
<text class="word word-large">{{item.en_text}}</text>
<text class="desc desc-spaced">{{item.zh_text}}</text>
</view>
<view class="badge badge-pink">✓</view>
</view>
<view class="progress-meta">
<text class="progress-label">神经元连接强度</text>
<text class="progress-value progress-pink">{{item.mastery_score}}%</text>
</view>
<view class="progress-track">
<view class="progress-fill progress-fill-pink" style="width: {{item.mastery_score}}%;"></view>
</view>
</view>
</block>
</view>
</view>
<view wx:if="{{!reviewList.length && !masteredList.length}}" class="empty-state small">
<text class="empty-title">暂无进度内容</text>
</view>
</view>
</scroll-view>
</view>

View File

@ -0,0 +1 @@
page{background:#0c0e12;color:#f6f6fc}.page{min-height:100vh;background:#0c0e12;box-sizing:border-box;position:relative;overflow:hidden}.bg-orb{position:absolute;border-radius:9999rpx;filter:blur(120rpx);pointer-events:none;z-index:0}.bg-orb-right{width:40vw;height:40vw;top:-10vw;right:-10vw;background:rgba(193,156,255,.08)}.bg-orb-left{width:50vw;height:50vw;left:-15vw;bottom:-15vw;background:rgba(0,227,253,.06)}.content{height:100vh;padding:40rpx 32rpx;box-sizing:border-box;position:relative;z-index:1}.header{margin-bottom:28rpx}.title{display:block;font-size:56rpx;line-height:1.1;font-weight:800;letter-spacing:-1rpx}.subtitle{display:block;margin-top:10rpx;color:#aaabb0;font-size:24rpx;line-height:1.5}.tabs{display:flex;gap:16rpx;padding:8rpx;border:1rpx solid rgba(255,255,255,.05);background:rgba(23,26,31,.72);border-radius:9999rpx;width:max-content;box-shadow:0 8rpx 30rpx rgba(0,0,0,.12);margin-bottom:34rpx}.tab{min-width:150rpx;padding:16rpx 30rpx;border-radius:9999rpx;color:#aaabb0;font-size:24rpx;font-weight:700;display:flex;align-items:center;justify-content:center;transition:all .2s ease}.tab-active{background:linear-gradient(180deg,rgba(193,156,255,.18),rgba(193,156,255,.08));color:#d8c7ff;box-shadow:0 0 0 1rpx rgba(193,156,255,.12) inset,0 10rpx 26rpx rgba(193,156,255,.08)}.tab-label{line-height:1}.section{margin-top:34rpx}.section-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:18rpx;padding:14rpx 4rpx}.section-title-wrap{display:flex;align-items:center;gap:14rpx;flex-wrap:wrap}.section-pill{padding:8rpx 18rpx;border-radius:9999rpx;font-size:20rpx;font-weight:700;letter-spacing:1rpx;line-height:1}.section-pill-pink{background:rgba(255,108,149,.12);color:#ff98b0;border:1rpx solid rgba(255,108,149,.12)}.section-pill-cyan{background:rgba(0,227,253,.1);color:#7defff;border:1rpx solid rgba(0,227,253,.1)}.section-title{font-size:30rpx;font-weight:800;color:#f6f6fc;letter-spacing:-.5rpx}.section-toggle-chip{min-width:114rpx;padding:12rpx 22rpx;border-radius:9999rpx;display:flex;align-items:center;justify-content:center;border:1rpx solid rgba(255,255,255,.06);background:rgba(255,255,255,.04)}.section-toggle-chip.expanded{background:linear-gradient(180deg,rgba(193,156,255,.16),rgba(193,156,255,.08));border-color:rgba(193,156,255,.14)}.section-toggle-chip.collapsed{background:rgba(255,255,255,.04)}.section-toggle-text{font-size:22rpx;font-weight:700;color:#d8c7ff;line-height:1}.grid{display:flex;flex-direction:column;gap:24rpx}.card{position:relative;overflow:hidden;padding:30rpx;background:rgba(29,32,37,.56);border:1rpx solid rgba(255,255,255,.08);border-radius:32rpx;box-shadow:0 16rpx 60rpx rgba(0,0,0,.12)}.card-glow{position:absolute;right:-40rpx;top:-40rpx;width:180rpx;height:180rpx;border-radius:9999rpx;filter:blur(30rpx);opacity:.35}.card-glow-pink{background:rgba(255,108,149,.5)}.card-glow-cyan{background:rgba(0,227,253,.45)}.card-head{display:flex;justify-content:space-between;align-items:flex-start;gap:20rpx;margin-bottom:18rpx}.word{display:block;font-weight:800;line-height:1.1}.word-large{font-size:48rpx;letter-spacing:-1rpx}.desc{display:block;color:#aaabb0;font-size:22rpx}.desc-spaced{margin-top:10rpx}.badge{width:80rpx;height:80rpx;border-radius:9999rpx;display:flex;align-items:center;justify-content:center;background:rgba(255,255,255,.04);border:1rpx solid rgba(255,255,255,.05);flex-shrink:0;font-size:28rpx}.badge-pink{color:#ff6c95}.badge-cyan{color:#00e3fd}.progress-meta{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:14rpx}.progress-label{font-size:20rpx;color:#aaabb0;letter-spacing:1rpx}.progress-value{font-size:28rpx;font-weight:800}.progress-pink{color:#ff6c95}.progress-cyan{color:#00e3fd}.progress-track{width:100%;height:8rpx;background:#23262c;border-radius:9999rpx;overflow:hidden;box-shadow:inset 0 0 0 1rpx rgba(255,255,255,.03);margin-bottom:20rpx}.progress-fill{height:100%;border-radius:9999rpx}.progress-fill-pink{background:linear-gradient(90deg,#ff769b,#ff6c95);box-shadow:0 0 20rpx rgba(255,108,149,.5)}.progress-fill-cyan{background:linear-gradient(90deg,#c19cff,#00e3fd);box-shadow:0 0 20rpx rgba(0,227,253,.45)}.action-btn{width:100%;padding:22rpx 0;border-radius:9999rpx;background:#1d2025;border:1rpx solid rgba(255,255,255,.05);color:#aaabb0;font-size:24rpx;font-weight:600;text-align:center}.empty-state{padding:60rpx 20rpx;text-align:center;color:#aaabb0}.empty-state.small{padding:40rpx 20rpx}.empty-title{display:block;font-size:28rpx;font-weight:700;color:#f6f6fc}.empty-subtitle{display:block;margin-top:10rpx;font-size:22rpx;color:#aaabb0}.error-banner{padding:24rpx 28rpx;border-radius:20rpx;background:rgba(167,1,56,.2);border:1rpx solid rgba(255,110,132,.35)}.error-text{color:#ffb2b9;font-size:24rpx}.cyan{color:#00e5ff}.purple{color:#c19cff}.pink{color:#ff4081}

View File

@ -0,0 +1,120 @@
const app = getApp()
Page({
data: {
timeRanges: ['??', '??', '??'],
activeTimeRange: 0,
contentTypes: ['??', '??'],
activeContentType: 0,
modes: ['?? -> ????', '?? -> ????', '??????'],
activeMode: 0,
onlyReview: true,
shuffle: false,
reviewOrder: 'sequential',
questionCount: 30,
loading: false,
error: '',
},
onLoad() {
const plan = app.globalData.reviewPlan
this.setData({
activeTimeRange: plan.timeRangeIndex,
activeContentType: plan.contentTypeIndex,
activeMode: plan.modeIndex,
onlyReview: plan.onlyReview,
shuffle: plan.reviewOrder === 'shuffle' || plan.shuffle,
reviewOrder: plan.reviewOrder || (plan.shuffle ? 'shuffle' : 'sequential'),
questionCount: plan.questionCount,
})
},
persistPatch(patch) {
app.globalData.reviewPlan = { ...app.globalData.reviewPlan, ...patch }
},
setTimeRange(e) {
const activeTimeRange = Number(e.currentTarget.dataset.index)
this.setData({ activeTimeRange })
this.persistPatch({ timeRangeIndex: activeTimeRange })
},
setContentType(e) {
const activeContentType = Number(e.currentTarget.dataset.index)
this.setData({ activeContentType })
this.persistPatch({ contentTypeIndex: activeContentType })
},
setMode(e) {
const activeMode = Number(e.currentTarget.dataset.index)
this.setData({ activeMode })
this.persistPatch({ modeIndex: activeMode })
},
toggleOnlyReview() {
const onlyReview = !this.data.onlyReview
this.setData({ onlyReview })
this.persistPatch({ onlyReview })
},
toggleShuffle() {
const shuffle = !this.data.shuffle
this.setData({ shuffle, reviewOrder: shuffle ? 'shuffle' : 'sequential' })
this.persistPatch({ shuffle, reviewOrder: shuffle ? 'shuffle' : 'sequential' })
},
onQuestionCountInput(e) {
const questionCount = Number(e.detail.value || 0)
this.setData({ questionCount })
this.persistPatch({ questionCount })
},
startReview() {
const scopeTypeMap = ['today', 'week', 'month']
const contentTypeMap = ['word', 'sentence']
const questionModeMap = ['A', 'B', 'C']
const scope_type = scopeTypeMap[this.data.activeTimeRange]
const content_type = contentTypeMap[this.data.activeContentType]
const question_mode = questionModeMap[this.data.activeMode]
const scope_config = {}
this.setData({ loading: true, error: '' })
app.ensureLogin().then((openid) => {
if (!openid) {
this.setData({ loading: false, error: '??????????' })
return
}
wx.request({
url: `${app.globalData.baseUrl}/api/v1/review/generate`,
method: 'POST',
header: { 'Content-Type': 'application/json', 'X-OpenID': openid },
data: {
scope_type,
scope_config,
content_type,
question_mode,
review_strategy: this.data.onlyReview ? 'review_only' : 'all',
sort_mode: this.data.reviewOrder === 'shuffle' ? 'shuffle' : 'sequential',
total_count: this.data.questionCount,
},
success: (res) => {
const session = res.data?.data || null
if (res.statusCode < 200 || res.statusCode >= 300 || !session?.id) {
const message = res.data?.detail || res.data?.message || '????????'
this.setData({ error: message })
wx.showToast({ title: message, icon: 'none' })
return
}
if (!session.record_ids?.length) {
const message = '??????????????????????????'
this.setData({ error: message })
wx.showToast({ title: message, icon: 'none' })
return
}
app.globalData.reviewSessionMeta = session
wx.setStorageSync('reviewSessionMeta', session)
wx.navigateTo({ url: '/pages/review/review-session/review-session' })
},
fail: () => this.setData({ error: '????????' }),
complete: () => this.setData({ loading: false }),
})
})
},
goHome() {
wx.reLaunch({ url: '/pages/index/index' })
},
goToReview() {},
goToMe() {
wx.navigateTo({ url: '/pages/me/me' })
},
})

View File

@ -0,0 +1,3 @@
{
"navigationBarTitleText": "复习配置"
}

View File

@ -0,0 +1,45 @@
<view class="page">
<scroll-view scroll-y class="content config-content">
<view class="heading">
<text class="title">配置复习计划</text>
<text class="subtitle">定制你的复习范围、题型和题量。</text>
</view>
<view class="config-card">
<text class="section-title">时间范围</text>
<view class="chip-row">
<text wx:for="{{timeRanges}}" wx:key="index" data-index="{{index}}" bindtap="setTimeRange" class="chip {{index===activeTimeRange?'active':''}}">{{item}}</text>
</view>
</view>
<view class="config-card">
<text class="section-title">内容类型</text>
<view class="two-grid">
<view wx:for="{{contentTypes}}" wx:key="index" data-index="{{index}}" bindtap="setContentType" class="option-box {{index===activeContentType?'active':''}}">{{item}}</view>
</view>
</view>
<view class="config-card">
<text class="section-title">训练模式</text>
<view class="mode-list">
<view wx:for="{{modes}}" wx:key="index" data-index="{{index}}" bindtap="setMode" class="mode-item {{index===activeMode?'active':''}}">{{item}}</view>
</view>
</view>
<view class="config-card">
<text class="section-title">策略微调</text>
<view class="switch-row" bindtap="toggleOnlyReview"><text>仅待复习</text><text class="switch {{onlyReview?'on':''}}">{{onlyReview ? 'ON' : 'OFF'}}</text></view>
<view class="switch-row" bindtap="toggleShuffle"><text>随机打乱</text><text class="switch {{shuffle?'on':''}}">{{shuffle ? 'ON' : 'OFF'}}</text></view>
<view class="range-row"><text>题量选择</text><text class="accent">{{questionCount}} 题</text></view>
<slider min="5" max="100" step="5" value="{{questionCount}}" activeColor="#c19cff" backgroundColor="#23262c" bindchanging="onQuestionCountInput" bindchange="onQuestionCountInput" />
</view>
<button class="start-btn" bindtap="startReview">开始复习</button>
</scroll-view>
<view class="bottom-nav">
<view class="nav-item" bindtap="goHome"><text class="nav-icon">⌂</text><text class="nav-label">首页</text></view>
<view class="nav-item active"><text class="nav-icon">📖</text><text class="nav-label">复习</text></view>
<view class="nav-item" bindtap="goToMe"><text class="nav-icon">☺</text><text class="nav-label">我的</text></view>
</view>
</view>

View File

@ -0,0 +1,26 @@
page { background:#0c0e12; color:#f6f6fc; }
.page { min-height:100vh; background:#0c0e12; box-sizing:border-box; }
.content { height:calc(100vh - 120rpx); padding:32rpx; box-sizing:border-box; }
.config-content { padding-bottom:180rpx; }
.heading { margin:16rpx 0 24rpx; }
.title { display:block; font-size:52rpx; font-weight:800; }
.subtitle { display:block; margin-top:10rpx; font-size:28rpx; color:#aaabb0; line-height:1.6; }
.config-card { margin-top:24rpx; padding:28rpx; border-radius:28rpx; background:rgba(29,32,37,.65); border:1rpx solid rgba(255,255,255,.08); }
.section-title { display:block; font-size:32rpx; font-weight:700; margin-bottom:20rpx; }
.chip-row { display:flex; flex-wrap:wrap; gap:16rpx; }
.chip { padding:18rpx 28rpx; border-radius:9999rpx; background:#23262c; color:#aaabb0; }
.chip.active { background:#c19cff; color:#000; }
.two-grid { display:grid; grid-template-columns:1fr 1fr; gap:18rpx; }
.option-box, .mode-item { padding:24rpx; border-radius:24rpx; background:#111318; border:1rpx solid rgba(255,255,255,.08); text-align:center; color:#aaabb0; min-height:96rpx; display:flex; align-items:center; justify-content:center; }
.option-box.active, .mode-item.active { color:#c19cff; border-color:#c19cff; }
.mode-list { display:flex; flex-direction:column; gap:18rpx; }
.switch-row, .range-row { display:flex; justify-content:space-between; align-items:center; padding:12rpx 0; }
.switch { padding:6rpx 16rpx; border-radius:9999rpx; background:#23262c; color:#aaabb0; }
.switch.on { background:#c19cff; color:#000; }
.accent { color:#c19cff; font-weight:700; }
.start-btn { margin:32rpx 0 48rpx; padding:28rpx 0; border-radius:9999rpx; background:linear-gradient(90deg,#c19cff,#9146ff); color:#fff; font-size:34rpx; font-weight:800; box-shadow:0 0 20rpx rgba(193,156,255,.28); }
.bottom-nav { position:fixed; left:0; right:0; bottom:0; z-index:50; display:flex; justify-content:space-around; align-items:center; padding:20rpx 24rpx 40rpx; background:rgba(12,14,18,.7); backdrop-filter:blur(32rpx); border-top:1rpx solid rgba(255,255,255,.08); border-radius:32rpx 32rpx 0 0; box-shadow:0 -10rpx 40rpx rgba(0,0,0,.4); }
.nav-item { display:flex; flex-direction:column; align-items:center; gap:6rpx; color:#aaabb0; }
.nav-item.active { color:#00e3fd; transform:translateY(-6rpx); }
.nav-icon { width:56rpx; height:56rpx; border-radius:9999rpx; background:rgba(255,255,255,.05); display:flex; align-items:center; justify-content:center; }
.nav-label { font-size:20rpx; }

View File

@ -0,0 +1,80 @@
const app = getApp()
Page({
data: {
accuracy: 0,
timeUsed: '--:--',
total: 0,
mastered: 0,
reviewNeeded: 0,
improved: 0,
regressed: 0,
loading: false,
error: '',
clozeResults: [],
},
onLoad() {
this.loadResult()
},
loadResult() {
const sessionId = app.globalData.reviewSessionMeta?.id
const fallbackSession = app.globalData.reviewSession
if (!sessionId) {
if (fallbackSession?.completed) {
this.setData({ error: '本地复习已完成,但结果会话信息已丢失' })
} else {
this.setData({ error: '暂无复习结果' })
}
return
}
this.setData({ loading: true, error: '' })
wx.request({
url: `${app.globalData.baseUrl}/api/v1/review-result?session_id=${sessionId}`,
header: { 'X-OpenID': app.globalData.openid || '' },
success: (res) => {
const data = res.data?.data || {}
const total = Number(data.total || 0)
const correct = Number(data.correct || 0)
const wrong = Number(data.wrong || Math.max(0, total - correct))
const accuracy = total ? Math.round((correct / total) * 100) : 0
const clozeResults = Array.isArray(data.cloze_results)
? data.cloze_results.map((item) => ({
record_id: item.record_id,
entry_id: item.entry_id,
question_mode: item.question_mode,
user_answer: item.user_answer || '',
prompt_text: item.cloze_question?.prompt_text || '',
blanks: Array.isArray(item.blanks)
? item.blanks.map((blank) => ({
index: blank.index,
user_answer: blank.user_answer || '',
correct_answers: Array.isArray(blank.correct_answers) ? blank.correct_answers : [],
is_correct: !!blank.is_correct,
}))
: [],
is_correct: !!item.blanks?.every?.((blank) => blank.is_correct),
}))
: []
this.setData({
total,
accuracy,
mastered: Number(data.mastered ?? correct),
reviewNeeded: Number(data.review_needed ?? wrong),
improved: Number(data.improved ?? 0),
regressed: Number(data.regressed ?? 0),
timeUsed: data.time_used || '--:--',
clozeResults,
})
},
fail: () => this.setData({ error: '结果加载失败' }),
complete: () => this.setData({ loading: false }),
})
},
restart() {
app.globalData.reviewSession = { deck: [], currentIndex: 0, correctCount: 0, wrongCount: 0, completed: false, currentAnswer: '' }
wx.reLaunch({ url: '/pages/review/review-config/review-config' })
},
goHome() {
wx.reLaunch({ url: '/pages/index/index' })
},
})

View File

@ -0,0 +1,3 @@
{
"usingComponents": {}
}

View File

@ -0,0 +1,91 @@
<view class="page">
<view class="bg-orb orb1"></view>
<view class="bg-orb orb2"></view>
<view class="hero">
<view class="badge">
<view class="badge-inner">🏆</view>
</view>
<view class="hero-copy">
<text class="headline">复习结果</text>
<text class="subtitle">本次复习已完成</text>
</view>
</view>
<view wx:if="{{error}}" class="empty-state">
<text class="empty-title">{{error}}</text>
<text class="empty-subtitle">可重新进入结果页,或再完成一次复习会话</text>
</view>
<view wx:elif="{{!total}}" class="empty-state">
<text class="empty-title">暂无复习结果</text>
<text class="empty-subtitle">请先完成一次复习会话</text>
</view>
<view wx:else class="grid">
<view class="hero-card">
<view class="hero-card-head">
<view>
<text class="metric-label">正确率</text>
<view class="metric-row">
<text class="metric-big">{{accuracy}}</text>
<text class="metric-unit">%</text>
</view>
</view>
<view class="ring">✓</view>
</view>
</view>
<view class="mini-card">
<view class="mini-head"><text class="mini-ico">⏱</text><text class="mini-label">耗时</text></view>
<text class="mini-value">{{timeUsed}}</text>
</view>
<view class="mini-card">
<view class="mini-head"><text class="mini-ico">📚</text><text class="mini-label">总题数</text></view>
<text class="mini-value">{{total}}</text>
</view>
<view class="breakdown">
<view class="break-item">
<text class="break-num cyan">{{mastered}}</text>
<text class="break-label">已掌握</text>
</view>
<view class="break-item">
<text class="break-num pink">{{reviewNeeded}}</text>
<text class="break-label">需加强</text>
</view>
<view class="break-item">
<text class="break-num green">{{improved}}</text>
<text class="break-label">进步题目</text>
</view>
<view class="break-item">
<text class="break-num amber">{{regressed}}</text>
<text class="break-label">退步题目</text>
</view>
</view>
<view wx:if="{{clozeResults.length}}" class="cloze-panel">
<text class="panel-title">填空逐空反馈</text>
<view wx:for="{{clozeResults}}" wx:for-item="item" wx:for-index="itemIndex" wx:key="record_id" class="cloze-result-card">
<view class="cloze-result-head">
<text class="cloze-result-index">题目 {{itemIndex + 1}}</text>
<text class="cloze-result-status {{item.is_correct ? 'ok' : 'bad'}}">{{item.is_correct ? '正确' : '错误'}}</text>
</view>
<view wx:if="{{item.prompt_text}}" class="cloze-result-prompt">{{item.prompt_text}}</view>
<view class="cloze-result-body">
<view wx:for="{{item.blanks}}" wx:for-item="blank" wx:for-index="blankIndex" wx:key="index" class="cloze-blank-line">
<text class="cloze-result-answer">空 {{blankIndex + 1}}{{blank.user_answer || '(空)'}}</text>
<text class="cloze-result-answer">答案:{{blank.correct_answers.join(' / ') || '—'}}</text>
</view>
</view>
</view>
</view>
</view>
<view class="actions">
<button class="primary-btn" bindtap="restart">再来一组</button>
<button class="secondary-btn" bindtap="goHome">返回首页</button>
</view>
</view>

View File

@ -0,0 +1 @@
page{background:#0c0e12;color:#f6f6fc}.page{min-height:100vh;padding:48rpx 32rpx 60rpx;box-sizing:border-box;background:#0c0e12;position:relative;overflow:hidden}.bg-orb{position:absolute;border-radius:9999rpx;pointer-events:none;filter:blur(120rpx);opacity:.2;z-index:0}.orb1{top:18%;left:50%;width:520rpx;height:520rpx;transform:translate(-50%,-50%);background:#ff6c95}.orb2{bottom:18%;left:50%;width:380rpx;height:380rpx;transform:translate(-50%,50%);background:#00e5ff}.hero,.grid,.actions{position:relative;z-index:1}.hero{display:flex;flex-direction:column;align-items:center;gap:22rpx;margin:26rpx 0 44rpx}.badge{width:144rpx;height:144rpx;border-radius:9999rpx;display:flex;align-items:center;justify-content:center;background:rgba(255,255,255,.06);box-shadow:0 0 30rpx rgba(255,108,149,.2)}.badge-inner{width:100%;height:100%;border-radius:9999rpx;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,rgba(255,108,149,.2),rgba(0,229,255,.2));font-size:56rpx}.headline{display:block;font-size:54rpx;font-weight:800;text-align:center}.subtitle{display:block;margin-top:8rpx;color:#00e5ff;letter-spacing:4rpx;text-transform:uppercase;text-align:center}.grid{display:grid;grid-template-columns:1fr 1fr;gap:18rpx}.hero-card{grid-column:1/-1;background:rgba(29,32,37,.7);backdrop-filter:blur(20rpx);border:1rpx solid rgba(255,255,255,.1);border-radius:28rpx;padding:32rpx;box-sizing:border-box;box-shadow:0 20rpx 40rpx rgba(0,0,0,.4);position:relative;overflow:hidden}.hero-card:after{content:'';position:absolute;top:-40rpx;right:-40rpx;width:160rpx;height:160rpx;border-radius:9999rpx;background:rgba(193,156,255,.1);filter:blur(48rpx)}.hero-card-head{display:flex;justify-content:space-between;align-items:center;position:relative;z-index:1}.metric-label{display:block;color:#aaabb0;font-size:20rpx;letter-spacing:4rpx;text-transform:uppercase}.metric-row{display:flex;align-items:baseline;gap:10rpx}.metric-big{font-size:88rpx;font-weight:800;color:#fff;line-height:1}.metric-unit{font-size:32rpx;color:#aaabb0;font-weight:700}.ring{width:124rpx;height:124rpx;border-radius:9999rpx;border:4rpx solid #23262c;display:flex;align-items:center;justify-content:center;color:#c19cff;font-size:52rpx;position:relative}.mini-card{background:rgba(29,32,37,.7);backdrop-filter:blur(20rpx);border:1rpx solid rgba(255,255,255,.1);border-radius:28rpx;padding:24rpx;box-sizing:border-box;box-shadow:0 20rpx 40rpx rgba(0,0,0,.4);display:flex;flex-direction:column;gap:18rpx}.mini-head{display:flex;align-items:center;gap:14rpx}.mini-ico{width:56rpx;height:56rpx;border-radius:9999rpx;background:rgba(255,255,255,.06);display:flex;align-items:center;justify-content:center;font-size:28rpx}.mini-label{color:#aaabb0;font-size:20rpx;letter-spacing:4rpx;text-transform:uppercase}.mini-value{font-size:36rpx;font-weight:800}.breakdown{grid-column:1/-1;display:grid;grid-template-columns:1fr 1fr;gap:0;border-radius:28rpx;overflow:hidden;border:1rpx solid rgba(255,255,255,.1)}.break-item{background:#1d2025;padding:30rpx;text-align:center}.break-num{display:block;font-size:54rpx;font-weight:800}.cyan{color:#00e5ff}.pink{color:#ff6c95}.break-label{display:block;margin-top:10rpx;color:#aaabb0;font-size:24rpx}.cloze-panel{grid-column:1/-1;margin-top:8rpx;padding:28rpx;border-radius:28rpx;background:rgba(29,32,37,.7);border:1rpx solid rgba(255,255,255,.1);box-shadow:0 20rpx 40rpx rgba(0,0,0,.28)}.panel-title{display:block;font-size:28rpx;font-weight:800;margin-bottom:18rpx;color:#f6f6fc}.cloze-result-card{padding:22rpx 20rpx;border-radius:22rpx;background:rgba(255,255,255,.04);border:1rpx solid rgba(255,255,255,.06);margin-bottom:16rpx}.cloze-result-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:12rpx}.cloze-result-index{font-size:24rpx;color:#aaabb0}.cloze-result-status{font-size:22rpx;font-weight:700;padding:8rpx 16rpx;border-radius:9999rpx}.cloze-result-status.ok{background:rgba(0,229,255,.12);color:#00e5ff}.cloze-result-status.bad{background:rgba(255,108,149,.12);color:#ff6c95}.cloze-result-prompt{margin-bottom:14rpx;color:#d8c7ff;font-size:24rpx;line-height:1.6}.cloze-result-body{display:flex;flex-direction:column;gap:10rpx}.cloze-blank-line{display:flex;flex-direction:column;gap:6rpx;padding:14rpx 14rpx;border-radius:16rpx;background:rgba(255,255,255,.03)}.cloze-result-answer{font-size:24rpx;line-height:1.6;color:#e8e9ee}.actions{display:flex;gap:18rpx;margin-top:28rpx;position:relative;z-index:1}.primary-btn,.secondary-btn{flex:1;border-radius:9999rpx;padding:24rpx 0;font-weight:800}.primary-btn{background:linear-gradient(90deg,#c19cff,#9146ff);color:#fff}.secondary-btn{background:#23262c;color:#f6f6fc}

View File

@ -0,0 +1,443 @@
const app = getApp()
const getReviewSettings = () => {
const settings = app.globalData.userSettings || {}
return {
autoNextQuestion: Number(settings.auto_next_question || 0) === 1,
showAnswerAfterSubmit: Number(settings.show_answer_after_submit || 0) === 1,
blankCount: Number(settings.blank_count || 2),
blankDifficulty: settings.blank_difficulty || 'normal',
}
}
Page({
data: {
progress: 0,
current: 1,
total: 0,
word: '',
meaning: '',
answer: '',
answers: [],
clozeAnswers: [],
feedback: '',
revealAnswer: '',
feedbackType: '',
showFeedback: false,
completed: false,
masterHint: false,
questions: [],
loading: false,
error: '',
settings: getReviewSettings(),
questionBadge: '',
entryTypeHint: '',
isCloze: false,
currentClozeQuestion: null,
currentClozeResults: [],
showClozeFeedback: false,
clozeSegments: [],
clozeFeedbackSummary: '',
activeClozeBlankIndex: 0,
},
submitting: false,
judged: false,
onLoad() {
this.loadSettingsAndQuestions()
},
loadSettingsAndQuestions() {
if (!app.globalData.openid) {
this.setData({ error: '????' })
return
}
wx.request({
url: `${app.globalData.baseUrl}/api/v1/settings`,
header: { 'X-OpenID': app.globalData.openid },
success: (res) => {
app.globalData.userSettings = res.data?.data || {}
this.setData({ settings: getReviewSettings() })
this.loadQuestions()
},
fail: () => {
this.setData({ error: '??????' })
this.loadQuestions()
},
})
},
buildClozeSegments(clozeQuestion) {
if (!clozeQuestion || !Array.isArray(clozeQuestion.blanks)) return []
const prompt = clozeQuestion.prompt_text || ''
const blanks = clozeQuestion.blanks || []
const segments = []
const useUnderscorePrompt = prompt.includes('____')
const workingPrompt = useUnderscorePrompt ? prompt : (clozeQuestion.original_text || prompt)
const pushWordBlank = (blankIndex, blank) => {
const answerText = String(blank?.answer || '')
const answerLength = Math.max(1, answerText.length || Number(blank?.hint?.length || 1))
const blankWidth = Math.max(88, Math.min(320, 32 + answerLength * 28))
segments.push({
type: 'word-blank',
blankIndex,
index: `blank-${blankIndex}`,
width: `${blankWidth}rpx`,
minWidth: `${blankWidth}rpx`,
maxLength: Math.max(1, answerLength),
})
}
if (useUnderscorePrompt) {
let blankIndex = 0
workingPrompt.split('____').forEach((text, index, parts) => {
if (text) segments.push({ type: 'text', text, index: `text-${index}` })
if (index < parts.length - 1) {
pushWordBlank(blankIndex, blanks[blankIndex])
blankIndex += 1
}
})
return segments
}
if (blanks.length === 1 && workingPrompt === (clozeQuestion.original_text || prompt)) {
// Keep the original word context visible for single-word cloze questions.
// The general start/end slicing below will render visible letters around the blank.
}
let cursor = 0
blanks.forEach((blank, idx) => {
const start = Math.max(0, Number(blank.start || 0))
const end = Math.max(start, Number(blank.end || start + 1))
if (start > cursor) {
segments.push({ type: 'text', text: workingPrompt.slice(cursor, start), index: `text-${idx}` })
}
pushWordBlank(idx, blank)
cursor = end
})
if (cursor < workingPrompt.length) {
segments.push({ type: 'text', text: workingPrompt.slice(cursor), index: `text-tail` })
}
return segments
},
loadQuestions() {
const session = app.globalData.reviewSessionMeta || wx.getStorageSync('reviewSessionMeta')
if (!session?.id) {
this.setData({ error: '??????' })
return
}
app.globalData.reviewSessionMeta = session
this.setData({ loading: true, error: '' })
wx.request({
url: `${app.globalData.baseUrl}/api/v1/review/questions?session_id=${session.id}`,
header: { 'X-OpenID': app.globalData.openid || '' },
success: (res) => {
if (res.statusCode < 200 || res.statusCode >= 300) {
const message = res.data?.detail || res.data?.message || '??????'
this.setData({ error: message, questions: [] })
return
}
const questions = (res.data?.data || []).map((item) => {
if (item.question_mode === 'C') {
return {
...item,
blank_prompt: item.blank_prompt || item.cloze_question?.prompt_text || item.prompt,
cloze_question: item.cloze_question || null,
}
}
return {
...item,
entry_type: item.entry_type || 'word',
}
})
if (!questions.length) {
this.setData({ error: '????????????????????', questions: [] })
return
}
app.globalData.reviewSession = {
deck: questions,
currentIndex: 0,
correctCount: 0,
wrongCount: 0,
completed: false,
currentAnswer: '',
currentAnswers: [],
startedAt: Date.now(),
}
this.setData({ questions })
this.syncSession()
},
fail: () => this.setData({ error: '??????' }),
complete: () => this.setData({ loading: false }),
})
},
syncSession() {
const session = app.globalData.reviewSession
const current = session.deck[session.currentIndex]
const total = session.deck.length || 1
const isCloze = current?.question_mode === 'C'
const clozeQuestion = isCloze ? (current?.cloze_question || null) : null
this.setData({
progress: Math.round((session.currentIndex / total) * 100),
current: session.currentIndex + 1,
total,
word: isCloze ? (current?.blank_prompt || clozeQuestion?.prompt_text || current?.prompt || '') : (current?.prompt || ''),
meaning: isCloze ? (current?.answer_hint || '') : '',
answer: session.currentAnswer || '',
clozeAnswers: isCloze ? (Array.isArray(session.currentAnswers) ? session.currentAnswers : Array.from({ length: clozeQuestion?.blanks?.length || 0 }, () => '')) : [],
activeClozeBlankIndex: 0,
feedback: '',
revealAnswer: '',
feedbackType: '',
showFeedback: false,
completed: session.completed,
masterHint: false,
questionBadge: isCloze ? `?? ? ${current?.entry_type === 'sentence' ? '??' : '??'}` : `${current?.entry_type === 'sentence' ? '??' : '??'} ? ${current?.question_mode || 'A'}`,
entryTypeHint: isCloze ? '??????????????????' : (current?.entry_type === 'sentence' ? '?????????????????' : ''),
isCloze,
currentClozeQuestion: clozeQuestion,
currentClozeResults: session.lastClozeResults || [],
showClozeFeedback: false,
clozeSegments: isCloze ? this.buildClozeSegments(clozeQuestion) : [],
clozeFeedbackSummary: '',
})
},
onAnswerInput(e) {
const answer = e.detail.value
app.globalData.reviewSession.currentAnswer = answer
this.setData({ answer, showFeedback: false, revealAnswer: '', feedback: '', feedbackType: '', judged: false, showClozeFeedback: false })
},
onClozeLetterInput(e) {
const { index, letterIndex } = e.currentTarget.dataset
const rawValue = e.detail.value || ''
const cleaned = rawValue.slice(-1)
const answers = [...(app.globalData.reviewSession.currentAnswers || [])]
const current = answers[index] || ''
const chars = current.split('')
chars[letterIndex] = cleaned
answers[index] = chars.join('').slice(0, Math.max(chars.length, letterIndex + 1))
app.globalData.reviewSession.currentAnswers = answers
this.setData({
[`clozeAnswers[${index}]`]: answers[index],
showFeedback: false,
revealAnswer: '',
feedback: '',
feedbackType: '',
judged: false,
showClozeFeedback: false,
}, () => {
if (cleaned) this.focusNextLetterCell(Number(index), Number(letterIndex) + 1)
})
},
focusNextLetterCell(blankIndex, letterIndex) {
const selector = `#cloze-input-${blankIndex}-${letterIndex}`
wx.nextTick(() => {
const query = wx.createSelectorQuery().in(this)
query.select(selector).fields({ node: true }).exec((res) => {
if (!res || !res[0]) return
const node = res[0].node
if (node && typeof node.focus === 'function') node.focus()
})
})
},
handleLetterKeydown(e) {
const key = e?.detail?.key || e?.detail?.value || ''
const { index, letterIndex } = e.currentTarget.dataset
if (key === 'Backspace' || key === 'Delete') {
const answers = [...(app.globalData.reviewSession.currentAnswers || [])]
const current = answers[index] || ''
const chars = current.split('')
const pos = Number(letterIndex)
if (chars[pos]) {
chars[pos] = ''
answers[index] = chars.join('')
app.globalData.reviewSession.currentAnswers = answers
this.setData({ [`clozeAnswers[${index}]`]: answers[index] })
return
}
const prevIndex = Number(letterIndex) - 1
if (prevIndex >= 0) this.focusNextLetterCell(Number(index), prevIndex)
return
}
if (key === 'ArrowLeft') {
const prevIndex = Number(letterIndex) - 1
if (prevIndex >= 0) this.focusNextLetterCell(Number(index), prevIndex)
return
}
if (key === 'ArrowRight' || key === ' ' || key === 'Space') {
this.focusNextLetterCell(Number(index), Number(letterIndex) + 1)
}
},
onClozeAnswerInput(e) {
const { index } = e.currentTarget.dataset
const rawValue = e.detail.value || ''
const clozeQuestion = this.data.currentClozeQuestion
const blank = clozeQuestion?.blanks?.[index]
const maxLength = Math.max(1, Number(blank?.answer?.length || 1))
const normalizedValue = rawValue.slice(-maxLength).slice(0, maxLength)
const answers = [...(app.globalData.reviewSession.currentAnswers || [])]
answers[index] = normalizedValue
app.globalData.reviewSession.currentAnswers = answers
const nextIndex = Number(index) + (normalizedValue.length >= maxLength ? 1 : 0)
const prevIndex = Number(index) - 1
this.setData({
[`clozeAnswers[${index}]`]: normalizedValue,
activeClozeBlankIndex: nextIndex,
showFeedback: false,
revealAnswer: '',
feedback: '',
feedbackType: '',
judged: false,
showClozeFeedback: false,
}, () => {
if (!normalizedValue && this.data.isCloze && rawValue === '') {
this.focusNextClozeInput(Math.max(0, prevIndex))
return
}
if (normalizedValue.length >= maxLength && this.data.isCloze) this.focusNextClozeInput(nextIndex)
})
},
focusNextClozeInput(nextIndex) {
const selector = `#cloze-input-${nextIndex}`
wx.nextTick(() => {
const query = wx.createSelectorQuery().in(this)
query.select(selector).fields({ node: true, size: true }).exec((res) => {
if (!res || !res[0]) return
const node = res[0].node
if (node && typeof node.focus === 'function') node.focus()
})
})
},
submitAnswer(showExplanation = false, advanceAfterSubmit = false) {
const session = app.globalData.reviewSession
const current = session.deck[session.currentIndex]
if (!current || this.submitting || this.judged) return
const answer = (this.data.answer || '').trim()
const clozeQuestion = current.cloze_question
const payload = {
session_id: app.globalData.reviewSessionMeta.id,
record_id: current.record_id,
user_answer: answer,
}
if (current.question_mode === 'C' && clozeQuestion?.blanks?.length) {
const answers = (this.data.clozeAnswers || []).map((item) => (item || '').trim())
while (answers.length < clozeQuestion.blanks.length) answers.push('')
const isLetterCellMode = Array.isArray(this.data.clozeSegments) && this.data.clozeSegments.some((segment) => segment.type === 'letter-cell')
const normalizedAnswers = isLetterCellMode
? answers
: answers
if (!normalizedAnswers.some((item) => item)) {
wx.showToast({ title: '??????', icon: 'none' })
return
}
payload.user_answers = normalizedAnswers
} else if (!answer) {
wx.showToast({ title: '??????', icon: 'none' })
return
}
this.submitting = true
wx.request({
url: `${app.globalData.baseUrl}/api/v1/review/submit`,
method: 'POST',
header: { 'Content-Type': 'application/json', 'X-OpenID': app.globalData.openid || '' },
data: payload,
success: (res) => {
const result = res.data?.data || {}
session.correctCount += result.is_correct ? 1 : 0
session.wrongCount += result.is_correct ? 0 : 1
this.judged = true
const shouldShowAnswer = showExplanation || this.data.settings.showAnswerAfterSubmit
const correctAnswerText = result.correct_answer_text || ''
const clozeResults = Array.isArray(result.cloze_results) ? result.cloze_results : []
const fallbackClozeResults = clozeResults.length
? clozeResults
: (this.data.currentClozeQuestion?.blanks || []).map((blank, idx) => ({
index: typeof blank?.index === 'number' ? blank.index : idx,
user_answer: '',
correct_answers: [blank?.answer, ...(Array.isArray(blank?.accepted_answers) ? blank.accepted_answers : [])].filter(Boolean),
correct_answer_text: blank?.answer || '',
is_correct: false,
}))
const normalizedClozeResults = fallbackClozeResults.map((item, idx) => {
const correctAnswers = Array.isArray(item.correct_answers)
? item.correct_answers.filter(Boolean)
: [item.correct_answer_text, ...(Array.isArray(item.accepted_answers) ? item.accepted_answers : [])].filter(Boolean)
const standardAnswerText = correctAnswers.join(' / ') || item.correct_answer_text || ''
return {
index: item.index ?? idx,
user_answer: item.user_answer ?? '',
correct_answers: correctAnswers,
correct_answer_text: item.correct_answer_text || standardAnswerText,
standard_answer_text: standardAnswerText,
is_correct: Boolean(item.is_correct),
}
})
const derivedClozeResults = normalizedClozeResults.length
? normalizedClozeResults
: (correctAnswerText
? [{ index: 0, user_answer: '', correct_answers: [correctAnswerText], correct_answer_text: correctAnswerText, standard_answer_text: correctAnswerText, is_correct: false }]
: [])
if (derivedClozeResults.length) {
session.lastClozeResults = derivedClozeResults
app.globalData.reviewSession.lastClozeResults = derivedClozeResults
}
const clozeFeedbackSummary = result.is_correct ? '????' : '????'
this.setData({
feedback: clozeFeedbackSummary,
feedbackType: result.is_correct ? 'correct' : 'wrong',
revealAnswer: '',
showFeedback: true,
showClozeFeedback: derivedClozeResults.length > 0,
currentClozeResults: derivedClozeResults,
clozeFeedbackSummary,
})
if (this.data.isCloze) {
app.globalData.reviewSession.currentAnswers = []
this.setData({ clozeAnswers: [] })
}
if (this.data.settings.autoNextQuestion || advanceAfterSubmit) {
const delay = shouldShowAnswer ? 2500 : 0
setTimeout(() => this.nextQuestion(), delay)
}
},
complete: () => {
this.submitting = false
},
})
},
checkAnswer() {
this.submitAnswer(true)
},
nextQuestion() {
const session = app.globalData.reviewSession
if (!session.deck.length) return
if (!this.judged) {
this.submitAnswer(true, true)
return
}
this.judged = false
if (session.currentIndex >= session.deck.length - 1) {
session.completed = true
session.endedAt = Date.now()
wx.request({
url: `${app.globalData.baseUrl}/api/v1/review/finish?session_id=${app.globalData.reviewSessionMeta.id}`,
method: 'POST',
header: { 'X-OpenID': app.globalData.openid || '' },
success: () => wx.navigateTo({ url: '/pages/review/review-result/review-result' }),
fail: () => {
wx.showToast({ title: '????????????', icon: 'none' })
wx.navigateTo({ url: '/pages/review/review-result/review-result' })
},
})
return
}
session.currentIndex += 1
session.currentAnswer = ''
session.currentAnswers = []
this.syncSession()
},
markMastered() {
this.setData({ masterHint: true })
wx.showToast({ title: '???', icon: 'success' })
},
})

View File

@ -0,0 +1,3 @@
{
"usingComponents": {}
}

View File

@ -0,0 +1,72 @@
<view class="page">
<scroll-view scroll-y class="content">
<view class="progress-box">
<view class="progress-head">
<text class="progress-label">复习进度</text>
<text class="progress-count">题号 {{current}} <text class="progress-total">/ {{total}}</text></text>
</view>
<view class="progress-bar ghost-border"><view class="progress-fill" style="width: {{progress}}%;"></view></view>
</view>
<view wx:if="{{!questions.length}}" class="empty-state">
<text class="empty-title">暂无题目</text>
<text class="empty-subtitle">{{error || '请先生成复习会话'}}</text>
</view>
<view wx:else class="glass-card question-card ghost-border">
<view class="ambient-glow"></view>
<view class="mode-tag-wrap">
<text class="mode-tag">{{questionBadge}}</text>
</view>
<view class="meaning-wrap" wx:if="{{meaning}}">
<text class="meaning">{{meaning}}</text>
</view>
<view wx:if="{{isCloze}}" class="cloze-wrap">
<view wx:if="{{currentClozeQuestion && currentClozeQuestion.blanks.length}}" class="inline-cloze">
<block wx:for="{{clozeSegments}}" wx:for-item="segment" wx:for-index="segmentIndex" wx:key="index">
<text wx:if="{{segment.type === 'text'}}" class="cloze-segment-text">{{segment.text}}</text>
<view wx:elif="{{segment.type === 'letter-cell'}}" class="cloze-letter-cell" style="width: {{segment.width}}; min-width: {{segment.minWidth}};">
<input id="cloze-input-{{segment.blankIndex}}-{{segment.letterIndex}}" focus="{{activeClozeBlankIndex === segment.blankIndex && segment.letterIndex === 0}}" class="answer inline-cloze-answer letter-cell-input" data-index="{{segment.blankIndex}}" data-letter-index="{{segment.letterIndex}}" value="{{clozeAnswers[segment.blankIndex] ? clozeAnswers[segment.blankIndex].charAt(segment.letterIndex) : ''}}" placeholder="" maxlength="1" bindinput="onClozeLetterInput" bindkeydown="handleLetterKeydown" />
<view class="cloze-letter-underline"></view>
</view>
<view wx:else class="cloze-blank-wrap" style="width: {{segment.width}}; min-width: {{segment.minWidth}};">
<input id="cloze-input-{{segment.blankIndex}}" focus="{{activeClozeBlankIndex === segment.blankIndex}}" class="answer inline-cloze-answer" data-index="{{segment.blankIndex}}" value="{{clozeAnswers[segment.blankIndex] || ''}}" placeholder="" maxlength="{{segment.maxLength}}" bindinput="onClozeAnswerInput" />
</view>
</block>
</view>
<text wx:else class="cloze">{{word}}</text>
</view>
<view wx:else class="word-wrap">
<text class="word">{{word}}</text>
</view>
<view class="entry-type-hint" wx:if="{{entryTypeHint}}">{{entryTypeHint}}</view>
<view class="input-shell" wx:if="{{!isCloze}}">
<input class="answer" value="{{answer}}" placeholder="输入答案..." bindinput="onAnswerInput" />
</view>
<view wx:if="{{showFeedback}}" class="feedback-box {{feedbackType}}">
<text class="feedback">{{clozeFeedbackSummary || feedback}}</text>
<view wx:if="{{showClozeFeedback && currentClozeResults.length}}" class="cloze-feedback-list">
<block wx:for="{{currentClozeResults}}" wx:for-item="blankResult" wx:for-index="blankIndex" wx:key="index">
<view class="cloze-feedback-item">
<text class="cloze-feedback-title">空 {{blankIndex + 1}}</text>
<text class="cloze-feedback-text">你的答案:{{blankResult.user_answer || '(空)'}}</text>
<text class="cloze-feedback-text">标准答案:{{blankResult.standard_answer_text || (blankResult.correct_answers && blankResult.correct_answers.length ? blankResult.correct_answers.join(' / ') : (blankResult.correct_answer_text || '(未返回)'))}}</text>
</view>
</block>
</view>
</view>
</view>
<view wx:if="{{questions.length}}" class="actions-cluster">
<button class="secondary-btn" bindtap="checkAnswer">查看解析</button>
<button class="primary-btn" bindtap="nextQuestion">下一题</button>
</view>
<view wx:if="{{questions.length}}" class="master-action" bindtap="markMastered">
<text class="master-text {{masterHint ? 'active' : ''}}">标记已掌握</text>
</view>
</scroll-view>
</view>

File diff suppressed because one or more lines are too long