diff --git a/packages/my/index.vue b/packages/my/index.vue
index 8a85fc5..3339855 100644
--- a/packages/my/index.vue
+++ b/packages/my/index.vue
@@ -9,8 +9,9 @@
-
- {{form.username}}
+
+
+ {{form.username}}
@@ -132,6 +133,7 @@
data() {
return {
userAvatar: '',
+ canFillUsername: false,
showMobile: false,
showSex: false,
myMobile: '',
@@ -179,6 +181,13 @@
value: '女'
}],
rules: {
+ username: [{
+ validator: (rule, value, callback) => {
+ return !this.canFillUsername || (typeof value === 'string' && value.trim().length > 0);
+ },
+ message: '请输入姓名',
+ trigger: ['blur', 'change'],
+ }],
sex: [{
required: true,
message: '请选择性别',
@@ -366,6 +375,8 @@
console.log("res", res)
// this.form = this.base.requestToForm(res.user, this.form)
this.form = this.base.deepCopy(res.user)
+ // Use the loaded name so typing does not hide the input.
+ this.canFillUsername = !String(res.user.username == null ? '' : res.user.username).trim()
// 头像
this.userAvatar = res.user && res.user.headimgurl ? res.user.headimgurl : ''
if (res.user.plate) {
diff --git a/packages/register/index.vue b/packages/register/index.vue
index c25a033..13ad655 100644
--- a/packages/register/index.vue
+++ b/packages/register/index.vue
@@ -30,7 +30,7 @@
- {{ saving ? '处理中…' : registrationComplete && fromBooking ? '返回场地预约' : registrationComplete && fromSupply ? '返回供需对接' : registrationComplete && survey_id ? '返回问卷' : '注册' }}
+ {{ saving ? '处理中…' : registrationComplete && fromBooking ? '返回场地预约' : registrationComplete && fromSupply ? '返回供需对接' : '注册' }}
@@ -53,7 +53,7 @@
- 确定
+ {{ bindingMobile || saving ? '处理中…' : '确定' }}
@@ -95,7 +95,9 @@
return { fromSupply: false, fromBooking: false,
survey_id: '',
saving: false,
+ bindingMobile: false,
registrationComplete: false,
+ lastRegisteredPayload: '',
showMobile: false,
showCompanySelector: false,
showCompanyInput: false,
@@ -134,6 +136,12 @@
},
form: {
username: '',
+ name: '',
+ mobile: '',
+ email: '',
+ birthday: '',
+ company_name: '',
+ company_position: '',
sex: ''
},
sexList: [{
@@ -218,6 +226,18 @@
this.fromSupply = options.from_supply === '1'
this.fromBooking = options.from_booking === '1'
this.survey_id = /^\d+$/.test(String(options.survey_id || '')) ? String(options.survey_id) : ''
+ if (this.survey_id && this.$u && this.$u.api && this.$u.api.user) {
+ this.$u.api.user().then(res => {
+ if (!res || !res.user) return
+ const user = res.user
+ const form = { ...this.form }
+ for (const key of ['username', 'sex', 'birthday', 'email', 'company_name', 'company_position', 'mobile']) {
+ const value = key === 'username' ? user.name : user[key]
+ if ((form[key] == null || !String(form[key]).trim()) && value != null) form[key] = value
+ }
+ this.form = form
+ }).catch(() => this.base.toast('读取个人资料失败,请重试'))
+ }
this.course_id = options.id ? options.id : null
this.isDonate = options.isDonate?options.isDonate:false
},
@@ -301,6 +321,8 @@
})
},
changeMobile() {
+ if (this.bindingMobile || this.saving) return
+ if (this.isBasicInfoIncomplete()) { this.base.toast('请先完善基础信息后再绑定'); return }
if (this.base.isNull(this.myMobile)) {
this.base.toast('请输入手机号')
return
@@ -310,16 +332,21 @@
return
}
const mobile = this.myMobile
+ this.bindingMobile = true
return this.$u.api.bindMobile({
mobile: this.myMobile,
code: this.myCode,
is_bind: 1
- }).then(res => {
- this.base.toast('绑定成功')
- this.form.mobile = this.myMobile
+ }).then(async res => {
+ if (!res || !res.id || res.errcode) return false
+ this.$set(this.form, 'mobile', mobile)
// that.myMobile = ''
this.myCode = ''
this.showMobile = false
+ // Wait for the verified phone to reach u-form's model before validating.
+ await this.$nextTick()
+ this.bindingMobile = false
+ return await this.saveUser({ stayOnPage: Boolean(this.survey_id) })
}).catch(error => {
const data = error && error.data
if (!data || data.errmsg !== '手机号已存在') return
@@ -348,7 +375,7 @@
})
}
})
- })
+ }).finally(() => { this.bindingMobile = false })
},
selectSex(e) {
this.form.sex = this.sexList[e[0]]['value']
@@ -400,61 +427,64 @@
this.manualCompanyName = '';
},
- saveUser() {
- if (this.saving) return
+ async saveUser(options = {}) {
+ if (this.saving || this.bindingMobile) return false
this.saving = true
- if (this.registrationComplete && (this.fromSupply || this.fromBooking)) return returnToSupply(this).catch(() => this.base.toast('返回页面失败,请重试')).finally(() => { this.saving = false })
- if (this.registrationComplete && this.survey_id) {
- return returnToSurvey(this.survey_id).catch(() => this.base.toast('返回问卷失败,请重试')).finally(() => { this.saving = false })
+ const stayOnPage = options && options.stayOnPage === true
+ const payload = { ...this.form, name: this.form.username }
+ if (this.survey_id) payload.survey_id = this.survey_id
+ const signature = JSON.stringify(payload)
+ let currentSaved = this.registrationComplete && signature === this.lastRegisteredPayload
+ try {
+ if (!stayOnPage && currentSaved && (this.fromSupply || this.fromBooking)) {
+ await returnToSupply(this)
+ return true
+ }
+ if (!stayOnPage && currentSaved && this.survey_id) {
+ await returnToSurvey(this.survey_id)
+ return true
+ }
+ const valid = await new Promise(resolve => this.$refs.uForm.validate(resolve))
+ if (!valid) {
+ this.base.toast('请检查并完善注册资料后重新提交')
+ return false
+ }
+ const res = await this.$u.api.saveUser(payload)
+ if (res == null || res === false || (typeof res === 'object' && res.errcode)) throw new Error('注册未成功')
+ currentSaved = true
+ this.registrationComplete = true
+ this.lastRegisteredPayload = signature
+ if (stayOnPage) {
+ this.base.toast('绑定并注册成功,请确认资料')
+ return true
+ }
+ if (this.fromSupply || this.fromBooking) {
+ this.registrationComplete = true
+ await returnToSupply(this)
+ return true
+ }
+ if (this.survey_id) {
+ this.registrationComplete = true
+ await returnToSurvey(this.survey_id)
+ return true
+ }
+ const profile = await this.$u.api.user()
+ this.$u.vuex('vuex_user', profile.user)
+ this.base.toast('注册成功', 2000, () => {
+ if (this.course_id) uni.redirectTo({ url: '/packages/course/detail?id=' + this.course_id })
+ else if (this.isDonate) uni.redirectTo({ url: '/packages/donate/index' })
+ else uni.switchTab({ url: '/pages/me/index' })
+ })
+ return true
+ } catch (error) {
+ this.base.toast(currentSaved
+ ? (this.fromBooking ? '注册已成功,返回场地预约失败,请重试' : this.fromSupply ? '注册已成功,返回供需对接失败,请重试' : '注册已成功,返回问卷失败,请点击返回问卷重试')
+ : '注册未完成,请检查提示后重试')
+ return false
+ } finally {
+ this.saving = false
}
- let that = this
- this.$refs.uForm.validate(valid => {
- if (valid) {
- this.form.name = this.form.username
- this.$u.api.saveUser(this.form).then(async res => {
- if (res == null || res === false) throw new Error('注册未成功')
- if (this.fromSupply || this.fromBooking) { this.registrationComplete = true; await returnToSupply(this); return }
- if (this.survey_id) {
- this.registrationComplete = true
- await returnToSurvey(this.survey_id)
- return
- }
- return this.$u.api.user().then(res => {
- this.$u.vuex('vuex_user', res.user)
- this.base.toast("注册成功", 2000, function() {
- console.log("that.course_id", that.course_id)
- if (that.course_id) {
- uni.redirectTo({
- url: '/packages/course/detail?id=' +
- that.course_id
- })
- // uni.redirectTo({
- // url:'/pages/course/index'
- // })
- }else if(that.isDonate){
- uni.redirectTo({
- url: '/packages/donate/index'
- })
- } else {
- uni.switchTab({
- url: '/pages/me/index'
- })
- }
- })
-
-
- })
- }).catch(() => {
- this.base.toast(this.registrationComplete ? (this.fromBooking ? '注册已成功,返回场地预约失败,请重试' : this.fromSupply ? '注册已成功,返回供需对接失败,请重试' : '注册已成功,返回问卷失败,请点击返回问卷重试') : '注册未完成,请检查提示后重试')
- }).finally(() => { this.saving = false })
- } else {
- this.saving = false
- console.log('验证失败');
- // this.base.toast("注册失败")
- }
- });
-
- },
+ },
}
}
diff --git a/packages/register/login.vue b/packages/register/login.vue
index d13491b..c797271 100644
--- a/packages/register/login.vue
+++ b/packages/register/login.vue
@@ -1,282 +1,282 @@
-
-
-
-
-
-
-
-
-
-
-
- {{hasSend?'已发送':'获取验证码'}}({{count}}s)
-
-
-
- {{ bindingComplete && fromBooking ? '返回场地预约' : bindingComplete && fromSupply ? '返回供需对接' : '登录' }}
-
-
- 注册账号
- 返回首页
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/packages/surveyFill/index.vue b/packages/surveyFill/index.vue
index 5a59206..aa8b1ac 100644
--- a/packages/surveyFill/index.vue
+++ b/packages/surveyFill/index.vue
@@ -1,7 +1,7 @@
-
+
{{ completion.answered }}/{{ completion.total }}
@@ -11,7 +11,10 @@
正在加载问卷…
- {{ error }}
+
+ {{ unavailableMessage || error }}
+
+
请先绑定或注册后填写问卷
@@ -85,6 +88,7 @@ export default {
questionList: [],
loading: false,
error: '',
+ unavailableMessage: '',
submitting: false,
uploadingFiles: {},
completed: false,
@@ -145,20 +149,67 @@ export default {
hasOther(q) {
return showOther(q, q.value)
},
- leaveUnavailable(message) {
- this.error = message
+ availabilityError(res) {
+ if (!res) return null
+ if (['draft', 'not_started', 'ended'].includes(res.access_state)) return res
+ // MySQL times use the backend's Asia/Shanghai timezone. Avoid iOS string parsing
+ // and the development computer's timezone when checking the opening window.
+ const parseTime = value => {
+ if (!value) return null
+ const parts = String(value).match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})$/)
+ if (!parts) return null
+ return { parts, timestamp: Date.UTC(+parts[1], +parts[2] - 1, +parts[3], +parts[4], +parts[5], +parts[6]) - 8 * 60 * 60 * 1000 }
+ }
+ const now = Date.now()
+ const start = parseTime(res.start_time)
+ if (start && start.timestamp > now) {
+ const [, year, month, day, hour, minute, second] = start.parts
+ const time = hour + ':' + minute + ':' + second
+ const display = year + '年' + month + '月' + day + '日' + (time === '00:00:00' ? '' : ' ' + time)
+ return { access_state: 'not_started', access_error: '该问卷尚未开始,请于' + display + '后扫码打开该问卷。' }
+ }
+ if (res.status !== undefined && res.status !== null && Number(res.status) !== 1) {
+ return { access_state: 'draft', access_error: '该问卷还未开始。' }
+ }
+ const end = parseTime(res.end_time)
+ if ((end && end.timestamp <= now) || res.access_error === '当前问卷已截止') {
+ return { access_state: 'ended', access_error: '该问卷已结束。' }
+ }
+ return null
+ },
+ handleAccessError(res) {
+ res = this.availabilityError(res) || res
+ if (!res || !res.access_error) return false
+ this.unavailableMessage = res.access_error
+ this.error = ''
+ this.survey = {}
+ this.questionList = []
+ this.completed = false
this.showRegister = false
- uni.showToast({ title: message, icon: 'none', duration: 2000 })
- setTimeout(() => uni.switchTab({ url: '/pages/index/index' }), 2000)
+ this.profileRequired = false
+ this.keyboardOpen = false
+ this.activeChapter = null
+ this.errorId = null
+ return true
},
async load() {
+ if (this.loading) return
this.loading = true
this.error = ''
+ this.unavailableMessage = ''
+ this.survey = {}
+ this.questionList = []
+ this.completed = false
+ this.showRegister = false
+ this.profileRequired = false
+ this.errorId = null
+ this.activeChapter = null
+ this.keyboardOpen = false
try {
const res = await this.$u.api.courseEvaluationDetail({
course_content_evaluation_id: this.id
})
- if (res && res.access_error) { this.leaveUnavailable(res.access_error); return }
+ if (this.handleAccessError(res)) return
if (!res || !res.id) throw new Error('问卷不存在')
const reply = res.my_response
this.completed = Boolean(reply)
@@ -213,7 +264,7 @@ export default {
},
submit() {
if (Object.values(this.uploadingFiles).some(Boolean)) return uni.showToast({ title: '请等待文件上传完成', icon: 'none' })
- if (this.submitting || this.profileRequired) return
+ if (this.submitting || this.profileRequired || this.loading || this.unavailableMessage || this.error) return
const error = firstError(this.questionList)
if (error) {
this.errorId = error.id
@@ -238,7 +289,7 @@ export default {
time_total: Math.floor((Date.now() - this.startedAt) / 1000),
data: submission(this.questionList)
})
- if (res && res.access_error) { this.leaveUnavailable(res.access_error); return }
+ if (this.handleAccessError(res)) return
if (!res || !res.id) throw new Error('提交失败,请检查提示后重试')
this.completed = true
uni.showToast({ title: '提交成功', icon: 'success' })
@@ -286,6 +337,37 @@ export default {
}
+ .survey-status {
+ margin: 100rpx 0 0;
+ padding: 64rpx 40rpx;
+ background: #fff;
+ border-radius: 28rpx;
+ box-shadow: 0 8rpx 32rpx rgba(32, 24, 160, 0.06);
+ text-align: center;
+ }
+ .survey-status-message {
+ display: block;
+ font-size: 30rpx;
+ line-height: 1.8;
+ color: #42465a;
+ word-break: break-all;
+ }
+ .survey-reload {
+ width: 280rpx;
+ margin: 40rpx auto 0;
+ padding: 0;
+ height: 80rpx;
+ line-height: 80rpx;
+ font-size: 28rpx;
+ font-weight: 500;
+ color: #fff;
+ background: linear-gradient(110deg, #6059bd, #2018a0);
+ border-radius: 40rpx;
+ box-shadow: 0 8rpx 20rpx rgba(32, 24, 160, 0.15);
+ }
+ .survey-reload::after { border: none; }
+ .survey-reload--pressed { opacity: 0.8; }
+
.survey-header {
background: white;
border-radius: 24rpx;
@@ -577,4 +659,4 @@ export default {
background: linear-gradient(to right, #5e5fbc, #0d0398);
}
}
-
\ No newline at end of file
+
diff --git a/tests/register.test.mjs b/tests/register.test.mjs
index 17e39f6..8dbf466 100644
--- a/tests/register.test.mjs
+++ b/tests/register.test.mjs
@@ -14,6 +14,8 @@ let modal, redirect
const uni = { showModal: options => { modal = options }, redirectTo: options => { redirect = options } }
const page = loadPage('../packages/register/index.vue', uni)
const state = {
+ $set: (target, key, value) => { target[key] = value }, $nextTick: () => Promise.resolve(),
+ isBasicInfoIncomplete: () => false, saveUser() { this.registrations = (this.registrations || 0) + 1 },
myMobile: '13800138000', myCode: '123456', form: {}, showMobile: true,
sendTimer: null, hasSend: true, count: 35, survey_id: '12', course_id: '34', isDonate: true,
base: { isNull: value => !value, toast() {} },
@@ -33,9 +35,11 @@ state.$u.api.bindMobile = async () => { throw { data: { errmsg: '验证码错误
state.myCode = '654321'
await page.methods.changeMobile.call(state)
assert.equal(modal, undefined)
+assert.equal(state.registrations || 0, 0)
state.$u.api.bindMobile = async () => ({ id: 1 })
await page.methods.changeMobile.call(state)
assert.equal(state.form.mobile, '13800138000')
+assert.equal(state.registrations, 1)
const login = loadPage('../packages/register/login.vue', uni)
const loginState = { form: { mobile: '' } }
login.onLoad.call(loginState, { mobile: '13800138000', survey_id: '12' })
@@ -45,3 +49,22 @@ loginState.form.mobile = ''
login.onLoad.call(loginState, { mobile: 'invalid' })
assert.equal(loginState.form.mobile, '')
console.log('Registration duplicate-phone and login prefill checks passed')
+
+for (const response of [false, null, undefined, { errcode: 1, errmsg: '验证码错误' }]) {
+ const before = state.registrations
+ state.myCode = '123456'
+ state.$u.api.bindMobile = async () => response
+ await page.methods.changeMobile.call(state)
+ assert.equal(state.registrations, before)
+ assert.equal(state.bindingMobile, false)
+}
+let completeBinding, requests = 0
+state.myCode = '123456'
+state.$u.api.bindMobile = () => { requests++; return new Promise(resolve => { completeBinding = resolve }) }
+const pending = page.methods.changeMobile.call(state)
+page.methods.changeMobile.call(state)
+assert.equal(requests, 1)
+completeBinding({ id: 1 })
+await pending
+assert.equal(state.registrations, 2)
+console.log('Verified phone binding auto-registers once; errors and duplicate clicks never register')
diff --git a/tests/survey.test.mjs b/tests/survey.test.mjs
index b4cb535..bfa718d 100644
--- a/tests/survey.test.mjs
+++ b/tests/survey.test.mjs
@@ -177,8 +177,9 @@ const savedQuestions = [
let toast
const previousUni = globalThis.uni
globalThis.uni = { showToast: args => { toast = args.title } }
+const completeProfile = { mobile: '13800000000', name: '测试学员', sex: '男', birthday: '1990-01-01', email: 'test@example.com', company_name: '测试公司', company_position: '经理' }
const returned = new Vue(page)
-returned.$u = { api: { user: async () => ({ user: { mobile: '13800000000' } }), courseEvaluationDetail: async () => ({ id: 8, title: '新标题', my_response: {
+returned.$u = { api: { user: async () => ({ user: completeProfile }), courseEvaluationDetail: async () => ({ id: 8, title: '新标题', my_response: {
title: '提交时标题', desc: '原说明
', questions: savedQuestions
} }) } }
await returned.load()
@@ -269,7 +270,7 @@ const registration = {
form: { username: '张三' },
$refs: { uForm: { validate: callback => callback(true) } },
base: { toast: noop },
- $u: { vuex: noop, api: { saveUser: async () => ({}), user: async () => ({ user: { mobile: '13800000000' } }) } }
+ $u: { vuex: noop, api: { saveUser: async () => ({}), user: async () => ({ user: completeProfile }) } }
}
registrationPage.onLoad.call(registration, { survey_id: '8' })
registrationPage.methods.saveUser.call(registration)
@@ -316,8 +317,9 @@ const freshRegistration = {
}
globalThis.uni = { redirectTo: args => { identityRoute = args.url; args.success() } }
identityRoute = ''
+const firstRegistration = registrationPage.methods.saveUser.call(freshRegistration)
registrationPage.methods.saveUser.call(freshRegistration)
-registrationPage.methods.saveUser.call(freshRegistration)
+await Promise.resolve()
assert.equal(saves, 1)
assert.equal(identityRoute, '')
completeSave('更新成功')
@@ -356,24 +358,103 @@ assert.deepEqual(submission(chained).map(q => q.id), [91])
chained[0].value = 'yes'
assert.equal(visibleQuestions(chained).length, 3)
-for (const message of ['当前问卷已截止', '您还未报名相关课程']) {
+for (const [state, message] of [
+ [undefined, '您还未报名相关课程'],
+ ['not_started', '该问卷尚未开始,请于2026年10月23日后扫码打开该问卷。'],
+ ['ended', '该问卷已结束。'],
+ ['draft', '该问卷还未开始。']
+]) {
const denied = new Vue(page)
denied.id = 9
- denied.$u = { api: { courseEvaluationDetail: async () => ({ id: 9, access_error: message }), user: () => { throw new Error('must not fetch profile') } } }
- let timer
- const originalTimeout = globalThis.setTimeout
- globalThis.setTimeout = callback => { timer = callback }
- let notice, destination
- globalThis.uni = { showToast: options => { notice = options.title }, switchTab: options => { destination = options.url } }
+ denied.questionList = prepareQuestions([{ id: 1, name: '旧问题', edit_input: 'text' }])
+ denied.survey = { title: '旧问卷' }
+ denied.showRegister = true
+ let calls = 0
+ denied.$u = { api: { courseEvaluationDetail: async () => { calls++; return { id: 9, access_state: state, access_error: message } },
+ user: () => { throw new Error('must not fetch profile') } } }
+ globalThis.uni = { showToast: () => { throw new Error('must show message on page') },
+ switchTab: () => { throw new Error('must stay on survey page') },
+ showModal: () => { throw new Error('must not submit unavailable survey') } }
+ await denied.load()
+ assert.equal(denied.unavailableMessage, message)
+ assert.equal(denied.error, '')
+ assert.equal(denied.loading, false)
+ assert.equal(denied.showRegister, false)
+ assert.deepEqual(denied.questionList, [])
+ assert.deepEqual(denied.survey, {})
+ denied.submit()
+ await denied.load()
+ assert.equal(calls, 2)
+ denied.$u.api.courseEvaluationDetail = async () => ({ id: 9, title: '已开始问卷', course_content_evaluation_asks: [{ id: 1, edit_input: 'text', name: '姓名' }] })
+ denied.$u.api.user = async () => ({ user: { mobile: '13800000000' } })
await denied.load()
- assert.equal(notice, message)
- assert.equal(denied.error, message)
- timer()
- assert.equal(destination, '/pages/index/index')
- globalThis.setTimeout = originalTimeout
+ assert.equal(denied.unavailableMessage, '')
+ assert.equal(denied.survey.title, '已开始问卷')
+ assert.equal(denied.questionList.length, 1)
denied.$destroy()
}
-console.log('Deadline and audience denial toast then return home verified')
+globalThis.uni = previousUni
+console.log('Draft, future and ended surveys hide stale content, stay on page, block submit and can reload after opening')
+
+// Access may be revoked after opening the form: submission must hide the old
+// questions as well, without a toast, delayed navigation or registration popup.
+const audienceRevoked = new Vue(page)
+audienceRevoked.id = 9
+audienceRevoked.survey = { title: '此前可填写', schema_hash: 'test' }
+audienceRevoked.questionList = prepareQuestions([{ id: 1, name: '意见', edit_input: 'text' }])
+audienceRevoked.questionList[0].value = '已填写内容'
+audienceRevoked.$u = { api: { courseContentForm: async () => ({ access_error: '您还未报名相关课程' }) } }
+let revokedSubmission
+globalThis.uni = {
+ showModal: args => { revokedSubmission = args.success({ confirm: true }) },
+ showToast: () => { throw new Error('must display the audience message on page') },
+ switchTab: () => { throw new Error('must not return home') }
+}
+audienceRevoked.submit()
+await revokedSubmission
+assert.equal(audienceRevoked.unavailableMessage, '您还未报名相关课程')
+assert.deepEqual(audienceRevoked.questionList, [])
+assert.deepEqual(audienceRevoked.survey, {})
+assert.equal(audienceRevoked.submitting, false)
+assert.equal(audienceRevoked.showRegister, false)
+audienceRevoked.$destroy()
+globalThis.uni = previousUni
+console.log('Audience denial at entry and submission hides questions, stays on page and supports reloading after approval')
+
+const actualNow = Date.now
+Date.now = () => Date.UTC(2026, 9, 22, 4, 0, 0) // 2026-10-22 12:00:00 Asia/Shanghai
+try {
+ for (const [fields, message] of [
+ [{ status: '0', start_time: '2026-10-26 00:00:00' }, '该问卷尚未开始,请于2026年10月26日后扫码打开该问卷。'],
+ [{ status: 0 }, '该问卷还未开始。'],
+ [{ status: '0', start_time: '2026-10-26 09:30:15' }, '该问卷尚未开始,请于2026年10月26日 09:30:15后扫码打开该问卷。'],
+ [{ status: '0', start_time: '2026-10-21 00:00:00' }, '该问卷还未开始。'],
+ [{ status: '1', start_time: '2026-10-26 00:00:00' }, '该问卷尚未开始,请于2026年10月26日后扫码打开该问卷。'],
+ [{ status: 1, start_time: '2026-10-26 09:30:15' }, '该问卷尚未开始,请于2026年10月26日 09:30:15后扫码打开该问卷。'],
+ [{ status: 1, end_time: '2026-10-22 12:00:00' }, '该问卷已结束。'],
+ [{ access_error: '当前问卷已截止' }, '该问卷已结束。']
+ ]) {
+ const denied = new Vue(page)
+ denied.id = 9
+ denied.$u = { api: { courseEvaluationDetail: async () => ({ id: 9, title: '不应显示', course_content_evaluation_asks: [{ id: 1, name: '不应显示的题目', edit_input: 'text' }], ...fields }),
+ user: () => { throw new Error('must not fetch profile before checking raw status and dates') } } }
+ globalThis.uni = { showToast: () => { throw new Error('must stay on message page') }, switchTab: () => { throw new Error('must not leave page') } }
+ await denied.load()
+ assert.equal(denied.unavailableMessage, message)
+ assert.equal(denied.error, '')
+ assert.deepEqual(denied.questionList, [])
+ assert.deepEqual(denied.survey, {})
+ denied.$destroy()
+ }
+ const available = new Vue(page)
+ assert.equal(available.availabilityError({ status: '1', start_time: '2026-10-22 12:00:00', end_time: '2026-10-23 00:00:00' }), null)
+ assert.equal(available.availabilityError({ status: '1', start_time: null, end_time: null }), null)
+ available.$destroy()
+} finally {
+ Date.now = actualNow
+ globalThis.uni = previousUni
+}
+console.log('Legacy full responses: string draft status, future opening, deadline and Shanghai timezone boundaries are blocked before profile/questions load')
const fileQuestions = prepareQuestions([{ id: 301, edit_input: 'file', name: '文件', need_fill: true, config: { max_files: 1 } }])
assert.ok(firstError(fileQuestions))
@@ -453,3 +534,131 @@ assert.equal(required(financing[1], financing), true)
financing[0].value = ['later']
assert.equal(required(financing[1], financing), false)
console.log('Multiple visibility triggers and required overrides verified')
+
+const incomplete = new Vue(page)
+incomplete.$u = { api: { user: async () => ({ user: { mobile: '13800000000' } }), courseEvaluationDetail: async () => ({ id: 9, requires_profile: true }) } }
+await incomplete.load()
+assert.equal(incomplete.profileRequired, false)
+assert.equal(incomplete.showRegister, false)
+incomplete.$destroy()
+identityRoute = ''
+globalThis.uni = { redirectTo: args => { identityRoute = args.url; args.success() }, removeStorageSync: noop, setStorageSync: noop }
+const needsProfile = { ...binding, binding: false, bindingComplete: false, survey_id: '9', $u: { vuex: noop, api: { checkMobile: async () => ({ token: 'test', requires_profile: true }) } } }
+await bindingPage.methods.handleMsgLogin.call(needsProfile)
+assert.equal(identityRoute, '/packages/surveyFill/index?id=9')
+const registrationPrefill = { form: {}, survey_id: '', base: { toast: noop }, $u: { api: { user: async () => ({ user: completeProfile }) } } }
+registrationPage.onLoad.call(registrationPrefill, { survey_id: '9' })
+await new Promise(resolve => setTimeout(resolve, 0))
+assert.equal(registrationPrefill.form.username, completeProfile.name)
+assert.equal(registrationPrefill.form.mobile, completeProfile.mobile)
+assert.equal(registrationPrefill.form.company_name, completeProfile.company_name)
+globalThis.uni = previousUni
+console.log('Mobile-only survey gate, binding return and verified profile prefill passed')
+
+let automaticSaves = 0
+identityRoute = ''
+globalThis.uni = { redirectTo: args => { identityRoute = args.url; args.success() } }
+const automaticRegistration = {
+ survey_id: '9', form: { ...completeProfile, username: completeProfile.name }, myMobile: completeProfile.mobile, myCode: '123456',
+ saving: false, bindingMobile: false, registrationComplete: false, isBasicInfoIncomplete: () => false,
+ $refs: { uForm: { validate: callback => callback(true) } },
+ base: { toast: noop, isNull: value => !value },
+ $u: { api: { bindMobile: async () => ({ id: 200, mobile: completeProfile.mobile }), saveUser: async payload => { automaticSaves++; assert.equal(payload.survey_id, '9'); return '更新成功' } } },
+ $set: (target, key, value) => { target[key] = value }, $nextTick: () => Promise.resolve(),
+ saveUser(options) { return registrationPage.methods.saveUser.call(this, options) }
+}
+await registrationPage.methods.changeMobile.call(automaticRegistration)
+await new Promise(resolve => setTimeout(resolve, 0))
+assert.equal(automaticSaves, 1)
+assert.equal(automaticRegistration.registrationComplete, true)
+assert.equal(identityRoute, '')
+automaticRegistration.form.company_position = '修改后职务'
+automaticRegistration.$u.api.saveUser = async payload => {
+ automaticSaves++
+ assert.equal(payload.company_position, '修改后职务')
+ return '更新成功'
+}
+assert.equal(await automaticRegistration.saveUser(), true)
+assert.equal(automaticSaves, 2)
+assert.equal(identityRoute, '/packages/surveyFill/index?id=9')
+globalThis.uni = previousUni
+console.log('Auto-registration stays on the page; explicit registration saves edits before returning')
+
+// Simulate the u-form child receiving its phone prop only on Vue's next update.
+let boundRequests = 0, registeredRequests = 0, finishRegistration
+const reactiveRegistration = new Vue(registrationPage)
+reactiveRegistration.form = { ...reactiveRegistration.form, ...completeProfile, username: completeProfile.name, mobile: '' }
+reactiveRegistration.survey_id = '9'
+reactiveRegistration.myMobile = completeProfile.mobile
+reactiveRegistration.myCode = '123456'
+reactiveRegistration.base = { isNull: value => value == null || !String(value).trim(), toast: noop }
+let renderedMobile = ''
+reactiveRegistration.$watch(() => reactiveRegistration.form.mobile, value => { renderedMobile = value })
+reactiveRegistration.$refs.uForm = { validate: callback => {
+ assert.equal(renderedMobile, completeProfile.mobile, 'the bound phone must reach the form before validation')
+ callback(true)
+} }
+reactiveRegistration.$u = { api: {
+ bindMobile: async () => { boundRequests++; return { id: 200, mobile: completeProfile.mobile } },
+ saveUser: payload => {
+ registeredRequests++
+ assert.equal(payload.mobile, completeProfile.mobile)
+ assert.equal(payload.survey_id, '9')
+ return new Promise(resolve => { finishRegistration = resolve })
+ }
+} }
+identityRoute = ''
+globalThis.uni = { redirectTo: options => { identityRoute = options.url; options.success() } }
+const registrationChain = reactiveRegistration.changeMobile()
+reactiveRegistration.changeMobile()
+await new Promise(resolve => setTimeout(resolve, 0))
+assert.equal(boundRequests, 1)
+assert.equal(registeredRequests, 1)
+assert.equal(identityRoute, '', 'must wait for the registration API to succeed')
+assert.equal(reactiveRegistration.saving, true)
+reactiveRegistration.changeMobile()
+assert.equal(boundRequests, 1)
+finishRegistration('更新成功')
+assert.equal(await registrationChain, true)
+assert.equal(identityRoute, '')
+reactiveRegistration.form.username = '修改后的姓名'
+const explicitRegistration = reactiveRegistration.saveUser()
+await new Promise(resolve => setTimeout(resolve, 0))
+assert.equal(registeredRequests, 2)
+assert.equal(identityRoute, '')
+finishRegistration('更新成功')
+assert.equal(await explicitRegistration, true)
+assert.equal(identityRoute, '/packages/surveyFill/index?id=9')
+assert.equal(reactiveRegistration.saving, false)
+assert.equal(reactiveRegistration.bindingMobile, false)
+reactiveRegistration.$destroy()
+globalThis.uni = previousUni
+console.log('Reactive phone sync and the full bind/register/return promise chain verified')
+
+// Saving or validation failures must not navigate; retry saving uses the verified phone.
+const failedRegistration = {
+ ...automaticRegistration, registrationComplete: false, saving: false, bindingMobile: false, myCode: '123456',
+ form: { ...automaticRegistration.form },
+ $u: { api: { bindMobile: async () => ({ id: 200 }), saveUser: async () => false } }
+}
+identityRoute = ''
+globalThis.uni = { redirectTo: options => { identityRoute = options.url; options.success() } }
+assert.equal(await registrationPage.methods.changeMobile.call(failedRegistration), false)
+assert.equal(failedRegistration.registrationComplete, false)
+assert.equal(failedRegistration.saving, false)
+assert.equal(identityRoute, '')
+failedRegistration.$u.api.saveUser = async () => '更新成功'
+assert.equal(await registrationPage.methods.saveUser.call(failedRegistration), true)
+assert.equal(identityRoute, '/packages/surveyFill/index?id=9')
+let invalidSaves = 0
+const invalidRegistration = {
+ ...failedRegistration, registrationComplete: false, saving: false, myCode: '123456',
+ $refs: { uForm: { validate: callback => callback(false) } },
+ $u: { api: { bindMobile: async () => ({ id: 200 }), saveUser: async () => { invalidSaves++; return '更新成功' } } }
+}
+identityRoute = ''
+assert.equal(await registrationPage.methods.changeMobile.call(invalidRegistration), false)
+assert.equal(invalidSaves, 0)
+assert.equal(identityRoute, '')
+globalThis.uni = previousUni
+console.log('Registration failure/validation failure never navigate; registration can retry without rebinding')