diff --git a/manage/batch-upload/batch-upload.js b/manage/batch-upload/batch-upload.js
new file mode 100644
index 0000000..9702b18
--- /dev/null
+++ b/manage/batch-upload/batch-upload.js
@@ -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 }),
+ })
+ },
+ })
+ },
+})
diff --git a/manage/batch-upload/batch-upload.json b/manage/batch-upload/batch-upload.json
new file mode 100644
index 0000000..8835af0
--- /dev/null
+++ b/manage/batch-upload/batch-upload.json
@@ -0,0 +1,3 @@
+{
+ "usingComponents": {}
+}
\ No newline at end of file
diff --git a/manage/batch-upload/batch-upload.wxml b/manage/batch-upload/batch-upload.wxml
new file mode 100644
index 0000000..83e2505
--- /dev/null
+++ b/manage/batch-upload/batch-upload.wxml
@@ -0,0 +1,25 @@
+
+
+
+
+ VocabMaster
+
+
+
+
+
+
+
+
+ 已选择:{{fileName}}
+ {{error}}
+
+ 总数:{{result.total}}
+ 成功:{{result.success}}
+ 失败:{{result.failed}}
+
+
+
diff --git a/manage/batch-upload/batch-upload.wxss b/manage/batch-upload/batch-upload.wxss
new file mode 100644
index 0000000..813983c
--- /dev/null
+++ b/manage/batch-upload/batch-upload.wxss
@@ -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)}
diff --git a/manage/entry-detail/entry-detail.js b/manage/entry-detail/entry-detail.js
new file mode 100644
index 0000000..942cb41
--- /dev/null
+++ b/manage/entry-detail/entry-detail.js
@@ -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()
+ },
+})
diff --git a/manage/entry-detail/entry-detail.json b/manage/entry-detail/entry-detail.json
new file mode 100644
index 0000000..8835af0
--- /dev/null
+++ b/manage/entry-detail/entry-detail.json
@@ -0,0 +1,3 @@
+{
+ "usingComponents": {}
+}
\ No newline at end of file
diff --git a/manage/entry-detail/entry-detail.wxml b/manage/entry-detail/entry-detail.wxml
new file mode 100644
index 0000000..a2f3670
--- /dev/null
+++ b/manage/entry-detail/entry-detail.wxml
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+ {{entry.entry_type === 'sentence' ? '句子' : '单词'}}
+
+ ✎
+ 🗑
+
+
+
+ {{entry.en_text}}
+ {{entry.entry_type === 'sentence' ? '翻译:' : '释义:'}}{{entry.zh_text}}
+
+ 来源:{{entry.source_type}}
+ 状态:{{entry.status}}
+
+
+
+
+
+
+ 加载中...
+
+
+
+ {{error}}
+
+
+
+
+ 类型
+ {{entry.entry_type === 'sentence' ? '句子' : '单词'}}
+
+
+ 内容
+ {{entry.en_text}}
+
+
+ {{entry.entry_type === 'sentence' ? '翻译' : '释义'}}
+ {{entry.zh_text}}
+
+
+ {{entry.entry_type === 'word' ? '例句' : '补充说明'}}
+ {{entry.example_text}}
+
+
+ 来源
+ {{entry.source_type}}
+
+
+
+
diff --git a/manage/entry-detail/entry-detail.wxss b/manage/entry-detail/entry-detail.wxss
new file mode 100644
index 0000000..96222db
--- /dev/null
+++ b/manage/entry-detail/entry-detail.wxss
@@ -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;
+}
diff --git a/manage/entry-edit/entry-edit.js b/manage/entry-edit/entry-edit.js
new file mode 100644
index 0000000..6b9f5d5
--- /dev/null
+++ b/manage/entry-edit/entry-edit.js
@@ -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 }),
+ })
+ },
+})
diff --git a/manage/entry-edit/entry-edit.json b/manage/entry-edit/entry-edit.json
new file mode 100644
index 0000000..8835af0
--- /dev/null
+++ b/manage/entry-edit/entry-edit.json
@@ -0,0 +1,3 @@
+{
+ "usingComponents": {}
+}
\ No newline at end of file
diff --git a/manage/entry-edit/entry-edit.wxml b/manage/entry-edit/entry-edit.wxml
new file mode 100644
index 0000000..bee4636
--- /dev/null
+++ b/manage/entry-edit/entry-edit.wxml
@@ -0,0 +1,65 @@
+
+
+
+
+
+ ←
+ {{isEditMode ? '编辑模式' : '新增模式'}}
+
+
+
+
+
+ {{isEditMode ? '更新词条' : '创建词条'}}
+ {{isEditMode ? '编辑内容' : '添加新内容'}}
+ {{entryType === 'word' ? '记录单词、释义和例句' : '记录句子、翻译和语境说明'}}
+
+ {{entryType === 'sentence' ? '句' : '词'}}
+
+
+
+
+ 单词
+ 句子
+
+
+
+
+ 编辑提示
+ 切换类型会改变字段含义,但不会自动清空你输入的内容。
+
+
+
+
+ {{entryType === 'word' ? '单词' : '句子'}}
+
+
+
+
+
+ {{entryType === 'word' ? '中文释义' : '中文翻译'}}
+
+
+
+
+
+ 例句 (可选)
+
+
+
+ 补充说明 (可选)
+
+
+
+
+
+
+
+
+
+
diff --git a/manage/entry-edit/entry-edit.wxss b/manage/entry-edit/entry-edit.wxss
new file mode 100644
index 0000000..02d2830
--- /dev/null
+++ b/manage/entry-edit/entry-edit.wxss
@@ -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);
+}
diff --git a/manage/logs/logs.js b/manage/logs/logs.js
new file mode 100644
index 0000000..85f6aac
--- /dev/null
+++ b/manage/logs/logs.js
@@ -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
+ }
+ })
+ })
+ }
+})
diff --git a/manage/logs/logs.json b/manage/logs/logs.json
new file mode 100644
index 0000000..7cfbc79
--- /dev/null
+++ b/manage/logs/logs.json
@@ -0,0 +1,3 @@
+{
+ "navigationBarTitleText": "日志"
+}
diff --git a/manage/logs/logs.wxml b/manage/logs/logs.wxml
new file mode 100644
index 0000000..85cf1bf
--- /dev/null
+++ b/manage/logs/logs.wxml
@@ -0,0 +1,6 @@
+
+
+
+ {{index + 1}}. {{log.date}}
+
+
diff --git a/manage/logs/logs.wxss b/manage/logs/logs.wxss
new file mode 100644
index 0000000..33f9d9e
--- /dev/null
+++ b/manage/logs/logs.wxss
@@ -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);
+}
diff --git a/manage/vocab-manage/vocab-manage.js b/manage/vocab-manage/vocab-manage.js
new file mode 100644
index 0000000..42ae5e2
--- /dev/null
+++ b/manage/vocab-manage/vocab-manage.js
@@ -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' })
+ },
+})
diff --git a/manage/vocab-manage/vocab-manage.json b/manage/vocab-manage/vocab-manage.json
new file mode 100644
index 0000000..8835af0
--- /dev/null
+++ b/manage/vocab-manage/vocab-manage.json
@@ -0,0 +1,3 @@
+{
+ "usingComponents": {}
+}
\ No newline at end of file
diff --git a/manage/vocab-manage/vocab-manage.wxml b/manage/vocab-manage/vocab-manage.wxml
new file mode 100644
index 0000000..d526582
--- /dev/null
+++ b/manage/vocab-manage/vocab-manage.wxml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+ 🔎
+ 搜索单词、短语或标签...
+
+
+
+
+ 加载中...
+ 正在同步词库数据
+
+
+
+ {{error}}
+
+
+
+ 暂无词条
+ 点击“新增”或“批量上传”开始构建词库
+
+
+
+
+
+
+
+ {{item.en_text}}
+ {{item.entry_type === 'sentence' ? '句子' : '单词'}}
+
+ {{item.status}}
+
+ {{item.entry_type === 'sentence' ? item.zh_text : item.zh_text}}
+ 例句:{{item.example_text}}
+ 说明:{{item.example_text}}
+
+
+
+
+
+ +
+
diff --git a/manage/vocab-manage/vocab-manage.wxss b/manage/vocab-manage/vocab-manage.wxss
new file mode 100644
index 0000000..eb08304
--- /dev/null
+++ b/manage/vocab-manage/vocab-manage.wxss
@@ -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)}
diff --git a/progress/achievements/achievements.js b/progress/achievements/achievements.js
new file mode 100644
index 0000000..f6ca52d
--- /dev/null
+++ b/progress/achievements/achievements.js
@@ -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 }),
+ })
+ },
+})
diff --git a/progress/achievements/achievements.json b/progress/achievements/achievements.json
new file mode 100644
index 0000000..8835af0
--- /dev/null
+++ b/progress/achievements/achievements.json
@@ -0,0 +1,3 @@
+{
+ "usingComponents": {}
+}
\ No newline at end of file
diff --git a/progress/achievements/achievements.wxml b/progress/achievements/achievements.wxml
new file mode 100644
index 0000000..0efa6a9
--- /dev/null
+++ b/progress/achievements/achievements.wxml
@@ -0,0 +1,72 @@
+
+
+
+ 成就墙
+ 把你的每一次坚持都挂在墙上
+
+
+
+
+
+
+ {{unlocked}}
+ 已点亮勋章 / {{total}}
+
+
+
+
+
+
+
+
+ 加载中...
+ 正在同步成就数据
+
+
+
+ {{error}}
+
+
+
+ 暂无成就
+ 继续复习和打卡后会逐渐解锁
+
+
+
+
+
+
+ {{item.title}}
+ {{item.sections.length}} 个分组
+
+
+
+
+
+
+
+ {{item.meta.title}}
+ {{item.meta.subtitle}}
+
+
+ {{item.items.length}} 项
+
+ {{item.progressText}}
+
+
+
+
+
+
+ {{item.achievement_name}}
+ {{item.progress_value}} / {{item.target_value}}
+
+
+
+
+
+
+
+
+
+
diff --git a/progress/achievements/achievements.wxss b/progress/achievements/achievements.wxss
new file mode 100644
index 0000000..9378307
--- /dev/null
+++ b/progress/achievements/achievements.wxss
@@ -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
+}
\ No newline at end of file
diff --git a/progress/checkin-detail/checkin-detail.js b/progress/checkin-detail/checkin-detail.js
new file mode 100644
index 0000000..64fefe5
--- /dev/null
+++ b/progress/checkin-detail/checkin-detail.js
@@ -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 }),
+ })
+ },
+})
diff --git a/progress/checkin-detail/checkin-detail.json b/progress/checkin-detail/checkin-detail.json
new file mode 100644
index 0000000..8835af0
--- /dev/null
+++ b/progress/checkin-detail/checkin-detail.json
@@ -0,0 +1,3 @@
+{
+ "usingComponents": {}
+}
\ No newline at end of file
diff --git a/progress/checkin-detail/checkin-detail.wxml b/progress/checkin-detail/checkin-detail.wxml
new file mode 100644
index 0000000..9bcab7e
--- /dev/null
+++ b/progress/checkin-detail/checkin-detail.wxml
@@ -0,0 +1,42 @@
+
+
+
+
+
+ 暂无打卡数据
+ 完成一次复习后即可自动生成打卡记录
+
+
+
+
+
+ {{stats.streak_days}}
+ 连续打卡天数
+
+ 🔥
+
+
+
+ 一二三四五六日
+
+
+
+
+
+
+ 总打卡次数{{stats.total_checkins}} 次
+
+
+
+ 本周打卡{{stats.week_checkins}} 次
+
+
+
+ 本月打卡{{stats.month_checkins}} 次
+
+
+
+
diff --git a/progress/checkin-detail/checkin-detail.wxss b/progress/checkin-detail/checkin-detail.wxss
new file mode 100644
index 0000000..2dcb788
--- /dev/null
+++ b/progress/checkin-detail/checkin-detail.wxss
@@ -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}
diff --git a/progress/progress-list/progress-list.js b/progress/progress-list/progress-list.js
new file mode 100644
index 0000000..2158244
--- /dev/null
+++ b/progress/progress-list/progress-list.js
@@ -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 }),
+ })
+ },
+})
diff --git a/progress/progress-list/progress-list.json b/progress/progress-list/progress-list.json
new file mode 100644
index 0000000..8835af0
--- /dev/null
+++ b/progress/progress-list/progress-list.json
@@ -0,0 +1,3 @@
+{
+ "usingComponents": {}
+}
\ No newline at end of file
diff --git a/progress/progress-list/progress-list.wxml b/progress/progress-list/progress-list.wxml
new file mode 100644
index 0000000..92c8fd5
--- /dev/null
+++ b/progress/progress-list/progress-list.wxml
@@ -0,0 +1,167 @@
+
+
+
+
+
+
+
+
+
+ 待复习
+
+
+ 已掌握
+
+
+
+
+ 加载中...
+ 正在同步进度数据
+
+
+
+ {{error}}
+
+
+
+
+
+
+ 待复习
+ 单词
+
+
+ {{reviewWordCollapsed ? '展开' : '收起'}}
+
+
+
+
+
+
+
+
+ {{item.en_text}}
+ {{item.zh_text}}
+
+ 🔥
+
+
+ 神经元连接强度
+ {{item.mastery_score}}%
+
+
+
+
+
+
+
+
+
+
+
+
+ 待复习
+ 句子
+
+
+ {{reviewSentenceCollapsed ? '展开' : '收起'}}
+
+
+
+
+
+
+
+
+ {{item.en_text}}
+ {{item.zh_text}}
+
+ 📝
+
+
+ 神经元连接强度
+ {{item.mastery_score}}%
+
+
+
+
+
+
+
+
+
+
+
+
+ 已掌握
+ 单词
+
+
+ {{masteredWordCollapsed ? '展开' : '收起'}}
+
+
+
+
+
+
+
+
+ {{item.en_text}}
+ {{item.zh_text}}
+
+ ✓
+
+
+ 神经元连接强度
+ {{item.mastery_score}}%
+
+
+
+
+
+
+
+
+
+
+
+
+ 已掌握
+ 句子
+
+
+ {{masteredSentenceCollapsed ? '展开' : '收起'}}
+
+
+
+
+
+
+
+
+ {{item.en_text}}
+ {{item.zh_text}}
+
+ ✓
+
+
+ 神经元连接强度
+ {{item.mastery_score}}%
+
+
+
+
+
+
+
+
+
+
+ 暂无进度内容
+
+
+
+
diff --git a/progress/progress-list/progress-list.wxss b/progress/progress-list/progress-list.wxss
new file mode 100644
index 0000000..b8919ed
--- /dev/null
+++ b/progress/progress-list/progress-list.wxss
@@ -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}
diff --git a/review/review-config/review-config.js b/review/review-config/review-config.js
new file mode 100644
index 0000000..cd1a5e0
--- /dev/null
+++ b/review/review-config/review-config.js
@@ -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' })
+ },
+})
diff --git a/review/review-config/review-config.json b/review/review-config/review-config.json
new file mode 100644
index 0000000..bb25044
--- /dev/null
+++ b/review/review-config/review-config.json
@@ -0,0 +1,3 @@
+{
+ "navigationBarTitleText": "复习配置"
+}
diff --git a/review/review-config/review-config.wxml b/review/review-config/review-config.wxml
new file mode 100644
index 0000000..6087b2c
--- /dev/null
+++ b/review/review-config/review-config.wxml
@@ -0,0 +1,45 @@
+
+
+
+ 配置复习计划
+ 定制你的复习范围、题型和题量。
+
+
+
+ 时间范围
+
+ {{item}}
+
+
+
+
+ 内容类型
+
+ {{item}}
+
+
+
+
+ 训练模式
+
+ {{item}}
+
+
+
+
+ 策略微调
+ 仅待复习{{onlyReview ? 'ON' : 'OFF'}}
+ 随机打乱{{shuffle ? 'ON' : 'OFF'}}
+ 题量选择{{questionCount}} 题
+
+
+
+
+
+
+
+ ⌂首页
+ 📖复习
+ ☺我的
+
+
diff --git a/review/review-config/review-config.wxss b/review/review-config/review-config.wxss
new file mode 100644
index 0000000..a3fe0f1
--- /dev/null
+++ b/review/review-config/review-config.wxss
@@ -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; }
diff --git a/review/review-result/review-result.js b/review/review-result/review-result.js
new file mode 100644
index 0000000..205f3b4
--- /dev/null
+++ b/review/review-result/review-result.js
@@ -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' })
+ },
+})
diff --git a/review/review-result/review-result.json b/review/review-result/review-result.json
new file mode 100644
index 0000000..8835af0
--- /dev/null
+++ b/review/review-result/review-result.json
@@ -0,0 +1,3 @@
+{
+ "usingComponents": {}
+}
\ No newline at end of file
diff --git a/review/review-result/review-result.wxml b/review/review-result/review-result.wxml
new file mode 100644
index 0000000..3a8e7be
--- /dev/null
+++ b/review/review-result/review-result.wxml
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+ 🏆
+
+
+ 复习结果
+ 本次复习已完成
+
+
+
+
+ {{error}}
+ 可重新进入结果页,或再完成一次复习会话
+
+
+
+ 暂无复习结果
+ 请先完成一次复习会话
+
+
+
+
+
+
+ 正确率
+
+ {{accuracy}}
+ %
+
+
+ ✓
+
+
+
+
+ ⏱耗时
+ {{timeUsed}}
+
+
+
+ 📚总题数
+ {{total}}
+
+
+
+
+ {{mastered}}
+ 已掌握
+
+
+ {{reviewNeeded}}
+ 需加强
+
+
+ {{improved}}
+ 进步题目
+
+
+ {{regressed}}
+ 退步题目
+
+
+
+
+ 填空逐空反馈
+
+
+ 题目 {{itemIndex + 1}}
+ {{item.is_correct ? '正确' : '错误'}}
+
+ {{item.prompt_text}}
+
+
+ 空 {{blankIndex + 1}}:{{blank.user_answer || '(空)'}}
+ 答案:{{blank.correct_answers.join(' / ') || '—'}}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/review/review-result/review-result.wxss b/review/review-result/review-result.wxss
new file mode 100644
index 0000000..4137ac1
--- /dev/null
+++ b/review/review-result/review-result.wxss
@@ -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}
diff --git a/review/review-session/review-session.js b/review/review-session/review-session.js
new file mode 100644
index 0000000..8dfef90
--- /dev/null
+++ b/review/review-session/review-session.js
@@ -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' })
+ },
+})
diff --git a/review/review-session/review-session.json b/review/review-session/review-session.json
new file mode 100644
index 0000000..8835af0
--- /dev/null
+++ b/review/review-session/review-session.json
@@ -0,0 +1,3 @@
+{
+ "usingComponents": {}
+}
\ No newline at end of file
diff --git a/review/review-session/review-session.wxml b/review/review-session/review-session.wxml
new file mode 100644
index 0000000..e8453f7
--- /dev/null
+++ b/review/review-session/review-session.wxml
@@ -0,0 +1,72 @@
+
+
+
+
+ 复习进度
+ 题号 {{current}} / {{total}}
+
+
+
+
+
+ 暂无题目
+ {{error || '请先生成复习会话'}}
+
+
+
+
+
+ {{questionBadge}}
+
+
+ {{meaning}}
+
+
+
+
+
+ {{segment.text}}
+
+
+
+
+
+
+
+
+
+ {{word}}
+
+
+
+ {{word}}
+
+ {{entryTypeHint}}
+
+
+
+
+
+ {{clozeFeedbackSummary || feedback}}
+
+
+
+ 空 {{blankIndex + 1}}
+ 你的答案:{{blankResult.user_answer || '(空)'}}
+ 标准答案:{{blankResult.standard_answer_text || (blankResult.correct_answers && blankResult.correct_answers.length ? blankResult.correct_answers.join(' / ') : (blankResult.correct_answer_text || '(未返回)'))}}
+
+
+
+
+
+
+
+
+
+
+
+
+ 标记已掌握
+
+
+
diff --git a/review/review-session/review-session.wxss b/review/review-session/review-session.wxss
new file mode 100644
index 0000000..0ffd469
--- /dev/null
+++ b/review/review-session/review-session.wxss
@@ -0,0 +1 @@
+page{background:#0c0e12;color:#f6f6fc}.page{min-height:100vh;background:#0c0e12}.topbar{display:flex;justify-content:space-between;align-items:center;padding:24rpx 32rpx;background:rgba(12,14,18,.8);border-bottom:1rpx solid rgba(255,255,255,.08);backdrop-filter:blur(24px);box-shadow:0 1rpx 20rpx rgba(0,0,0,.08)}.topbar-left{display:flex;align-items:center;gap:18rpx}.avatar-shell{width:80rpx;height:80rpx;border-radius:9999rpx;overflow:hidden;border:2rpx solid rgba(193,156,255,.2);flex-shrink:0}.avatar-img{width:100%;height:100%}.brand-text{font-size:38rpx;font-weight:800;color:#c19cff}.topbar-icon{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:32rpx;box-sizing:border-box}.progress-box{margin-bottom:28rpx}.progress-head{display:flex;justify-content:space-between;align-items:flex-end;margin-bottom:16rpx}.progress-label{font-size:24rpx;color:#aaabb0}.progress-count{font-size:28rpx;font-weight:700;color:#00e3fd}.progress-total{color:#aaabb0;font-size:22rpx;font-weight:400}.progress-bar{height:12rpx;background:#23262c;border-radius:9999rpx;overflow:hidden}.ghost-border{border:1rpx solid rgba(255,255,255,.1)}.progress-fill{height:100%;background:linear-gradient(90deg,#c19cff,#00e3fd)}.glass-card{background:rgba(29,32,37,.4);backdrop-filter:blur(24rpx)}.question-card{position:relative;overflow:hidden;border-radius:32rpx;padding:40rpx 32rpx;text-align:center;box-shadow:0 20rpx 40rpx rgba(0,0,0,.4);border:1rpx solid rgba(255,255,255,.1)}.ambient-glow{position:absolute;inset:0;background:linear-gradient(to bottom,rgba(193,156,255,.05),transparent);pointer-events:none}.mode-tag-wrap{display:flex;justify-content:center;margin-bottom:20rpx}.mode-tag{display:inline-flex;align-items:center;gap:10rpx;padding:10rpx 22rpx;border-radius:9999rpx;background:rgba(193,156,255,.12);border:1rpx solid rgba(193,156,255,.18);color:#d8c7ff;font-size:22rpx;letter-spacing:2rpx}.meaning-wrap{margin-bottom:28rpx}.meaning{display:block;color:#aaabb0;font-size:26rpx;letter-spacing:2rpx}.cloze-wrap,.word-wrap{margin:14rpx 0 10rpx}.cloze,.word{display:block;font-size:58rpx;font-weight:800;letter-spacing:2rpx;line-height:1.25}.cloze{color:#f6f6fc}.word{color:#f6f6fc}.entry-type-hint{margin-top:14rpx;color:#8f9198;font-size:22rpx;line-height:1.6}.inline-cloze{display:flex;flex-wrap:wrap;align-items:flex-end;justify-content:center;gap:10rpx;font-size:68rpx;font-weight:900;line-height:1.2;color:#f6f6fc;letter-spacing:1rpx}.cloze-segment-text{display:inline;white-space:pre-wrap;line-height:1.2}.cloze-blank-wrap{display:inline-flex;align-items:flex-end;justify-content:center;vertical-align:baseline;position:relative;top:2rpx;overflow:visible}.cloze-letter-cell{display:inline-flex;flex-direction:column;align-items:center;justify-content:flex-end;vertical-align:baseline}.cloze-blank-wrap .inline-cloze-answer{display:block;width:100%;min-width:100%;height:1.2em;padding:0;background:transparent;border:none;border-bottom:5rpx solid rgba(193,156,255,.98);border-radius:0;font-size:inherit;line-height:1.2;font-weight:900;color:#f6f6fc;text-align:center;caret-color:#c19cff;box-sizing:border-box;transform:translateY(-2rpx);min-width:88rpx}.letter-cell-input{font-size:68rpx;line-height:1.2;height:1.2em;padding:0}.cloze-letter-underline{width:100%;height:0}.inline-cloze-answer::placeholder{color:transparent}.cloze-input-list{margin-top:28rpx;display:flex;flex-direction:column;gap:20rpx}.cloze-input-item{padding:20rpx 18rpx;border-radius:24rpx;background:rgba(255,255,255,.04);border:1rpx solid rgba(255,255,255,.08);text-align:left}.cloze-input-label{display:block;margin-bottom:12rpx;color:#d8c7ff;font-size:22rpx;font-weight:700;letter-spacing:1rpx}.cloze-hint{margin-bottom:12rpx;color:#8f9198;font-size:20rpx;line-height:1.5}.input-shell{margin:26rpx auto 0;max-width:520rpx;background:rgba(255,255,255,.04);border:1rpx solid rgba(255,255,255,.08);border-radius:9999rpx}.answer{padding:22rpx 28rpx;text-align:center;color:#f6f6fc;font-size:32rpx;caret-color:#c19cff}.cloze-answer{max-width:100%;background:rgba(12,14,18,.35);border-radius:18rpx;border:1rpx solid rgba(255,255,255,.08);text-align:left;padding:22rpx 24rpx;margin:0 4rpx}.answer::placeholder{color:rgba(170,171,176,.72)}.answer::-webkit-input-placeholder{color:rgba(170,171,176,.72)}.answer::-moz-placeholder{color:rgba(170,171,176,.72)}.answer:-ms-input-placeholder{color:rgba(170,171,176,.72)}.feedback-box{margin-top:22rpx;padding:20rpx 24rpx;border-radius:24rpx;background:rgba(255,255,255,.04);border:1rpx solid rgba(255,255,255,.06)}.feedback{display:block;color:#00e3fd}.reveal-answer{display:block;margin-top:10rpx;color:#f6f6fc;font-weight:700}.cloze-feedback-list{margin-top:18rpx;display:flex;flex-direction:column;gap:14rpx}.cloze-feedback-item{padding:18rpx 16rpx;border-radius:18rpx;background:rgba(255,255,255,.04);border:1rpx solid rgba(255,255,255,.06)}.cloze-feedback-title{display:block;margin-bottom:8rpx;font-size:22rpx;font-weight:700;color:#c19cff}.cloze-feedback-text{display:block;font-size:22rpx;line-height:1.55;color:#e8e9ee}.actions-cluster{display:flex;flex-direction:row;gap:18rpx;justify-content:center;align-items:center;margin-top:24rpx}.secondary-btn,.primary-btn{flex:1;border-radius:9999rpx;padding:24rpx 0;font-weight:700}.secondary-btn{background:#23262c;color:#f6f6fc}.primary-btn{background:linear-gradient(90deg,#c19cff,#9146ff);color:#fff}.master-action{margin-top:34rpx;text-align:center}.master-text{color:#aaabb0;font-size:22rpx;border-bottom:1rpx solid transparent}.master-text.active{color:#00e5fd;border-color:#00e5fd}