index.vue 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. <template>
  2. <view class="page-index">
  3. <!-- Hero header -->
  4. <view class="hero">
  5. <text class="hero-title">数字能量</text>
  6. <text class="hero-sub">输入出生信息,解锁专属能量盘</text>
  7. </view>
  8. <!-- Birth input section -->
  9. <view class="input-section">
  10. <view class="input-card">
  11. <text class="label">姓名(选填)</text>
  12. <input
  13. class="input-field"
  14. v-model="name"
  15. placeholder="请输入姓名"
  16. />
  17. <text class="label mt-16">出生日期</text>
  18. <picker class="date-picker" mode="date" :value="birthday" :start="minDate" :end="maxDate" @change="onBirthdayChange">
  19. <view class="input-field date-field">
  20. <text class="date-val">{{ birthday }}</text>
  21. <text class="picker-arrow">▼</text>
  22. </view>
  23. </picker>
  24. <text class="label mt-16">想了解的问题(选填)</text>
  25. <view class="question-wrap">
  26. <textarea
  27. class="question-input"
  28. v-model="questions"
  29. maxlength="100"
  30. placeholder="例如:我的财运如何?最近适合换工作吗?感情方面有什么建议?"
  31. />
  32. <text class="char-counter">{{ questions.length }}/100</text>
  33. </view>
  34. <view class="quick-chips">
  35. <view
  36. v-for="q in quickQuestions"
  37. :key="q"
  38. class="chip"
  39. :class="{ active: questions === q }"
  40. @click="selectQuick(q)"
  41. >
  42. <text>{{ q }}</text>
  43. </view>
  44. </view>
  45. <button class="analyze-btn" @click="onAnalyze">开始咨询</button>
  46. <!-- US-4.1: Upgrade guide for free users -->
  47. <view v-if="!userStore.isVipActive" class="upgrade-banner" @click="goSubscribe">
  48. <text class="upgrade-banner-text">解锁无限解读 · 开通会员仅 ¥131/年 ›</text>
  49. </view>
  50. </view>
  51. </view>
  52. </view>
  53. </template>
  54. <script setup>
  55. import { ref, computed } from 'vue'
  56. import { onLoad } from '@dcloudio/uni-app'
  57. import { useChartStore } from '@/stores/chart'
  58. import { useUserStore } from '@/stores/user'
  59. import { consultationApi } from '@/utils/api'
  60. const store = useChartStore()
  61. const userStore = useUserStore()
  62. const name = ref('')
  63. const birthday = ref('1990-01-01')
  64. const questions = ref('')
  65. const confirming = ref(false) // prevent double-click
  66. const quickQuestions = ['职业发展', '财务分析', '感情运势', '健康建议']
  67. // Capture referral code from shared link
  68. onLoad((options) => {
  69. if (options.ref) {
  70. uni.setStorageSync('pending_referrer', options.ref.toUpperCase())
  71. }
  72. })
  73. // Date picker range: 1900-01-01 ~ today (US-1.2 §6)
  74. const minDate = '1900-01-01'
  75. const maxDate = computed(() => {
  76. const d = new Date()
  77. const y = d.getFullYear()
  78. const m = String(d.getMonth() + 1).padStart(2, '0')
  79. const day = String(d.getDate()).padStart(2, '0')
  80. return `${y}-${m}-${day}`
  81. })
  82. function selectQuick(q) {
  83. questions.value = questions.value === q ? '' : q
  84. }
  85. function onBirthdayChange(e) {
  86. birthday.value = e.detail.value
  87. }
  88. function parseBirthday(str) {
  89. const parts = str.split('-')
  90. return {
  91. year: parseInt(parts[0], 10),
  92. month: parseInt(parts[1], 10),
  93. day: parseInt(parts[2], 10)
  94. }
  95. }
  96. async function onAnalyze() {
  97. if (confirming.value) return
  98. const { year, month, day } = parseBirthday(birthday.value)
  99. if (!year || !month || !day) {
  100. uni.showToast({ title: '请选择出生日期', icon: 'none' })
  101. return
  102. }
  103. if (year < 1900 || year > new Date().getFullYear()) {
  104. uni.showToast({ title: '请输入正确的出生年份', icon: 'none' })
  105. return
  106. }
  107. if (month < 1 || month > 12) {
  108. uni.showToast({ title: '请输入正确的月份(1-12)', icon: 'none' })
  109. return
  110. }
  111. if (day < 1 || day > 31) {
  112. uni.showToast({ title: '请输入正确的日期(1-31)', icon: 'none' })
  113. return
  114. }
  115. const token = uni.getStorageSync('token')
  116. const birthdayStr = birthday.value
  117. confirming.value = true
  118. // US-6.2 §9: Login gate — not logged in → redirect to login with params
  119. if (!token) {
  120. confirming.value = false
  121. const params = new URLSearchParams({
  122. birthday: birthdayStr,
  123. name: name.value,
  124. questions: questions.value
  125. })
  126. uni.navigateTo({ url: `/pages/login/index?${params.toString()}` })
  127. return
  128. }
  129. try {
  130. // Check if this birthday has existing records — US-1.1 §7-9
  131. const isNew = await checkNewBirthday(birthdayStr)
  132. if (!isNew) {
  133. // Existing birthday — proceed directly (US-1.1 §7-8: returns existing record)
  134. confirming.value = false
  135. await proceedConsultation(year, month, day, birthdayStr)
  136. return
  137. }
  138. } catch (_e) {
  139. // If check fails (e.g. network), allow proceed without confirm
  140. }
  141. // New birthday — show confirm dialog (US-1.1 §9-11)
  142. uni.showModal({
  143. title: '确认咨询',
  144. content: '此生日将开启全新的能量盘咨询,确认吗?',
  145. confirmText: '确认开启',
  146. cancelText: '再想想',
  147. success: async (res) => {
  148. confirming.value = false
  149. if (res.confirm) {
  150. await proceedConsultation(year, month, day, birthdayStr)
  151. }
  152. // res.cancel → stay on page, do nothing (US-1.1 §11)
  153. },
  154. fail: () => { confirming.value = false }
  155. })
  156. }
  157. async function checkNewBirthday(birthday) {
  158. try {
  159. const result = await consultationApi.check({ birthday })
  160. return !result.exists
  161. } catch {
  162. return false
  163. }
  164. }
  165. async function proceedConsultation(year, month, day, birthday) {
  166. const result = await store.startConsultation(name.value, birthday, questions.value)
  167. // Parse backend chart data into birth props for TriangleChart
  168. if (store.currentChart) {
  169. store.currentBirthYear = year
  170. store.currentBirthMonth = month
  171. store.currentBirthDay = day
  172. }
  173. // Navigate with recordId for URL shareability (US-1.1 §14)
  174. const recordId = store.currentRecordId || (result.record && result.record.id)
  175. const url = recordId
  176. ? `/pages/chart/index?recordId=${recordId}`
  177. : '/pages/chart/index'
  178. uni.navigateTo({ url })
  179. }
  180. function goSubscribe() {
  181. uni.navigateTo({ url: '/pages/subscribe/index' })
  182. }
  183. </script>
  184. <style scoped lang="scss">
  185. .page-index {
  186. min-height: 100vh;
  187. padding-bottom: 40px;
  188. background: linear-gradient(180deg, #0c0a1a 0%, #120d28 60%, #0f0c1e 100%);
  189. }
  190. /* Hero */
  191. .hero {
  192. padding: 40px 20px 20px;
  193. text-align: center;
  194. }
  195. .hero-title {
  196. font-size: 32px;
  197. font-weight: 700;
  198. color: #fbbf24;
  199. text-shadow: 0 0 30px rgba(251, 191, 36, 0.2);
  200. letter-spacing: 4px;
  201. }
  202. .hero-sub {
  203. display: block;
  204. margin-top: 10px;
  205. font-size: 14px;
  206. color: rgba(255, 255, 255, 0.4);
  207. letter-spacing: 2px;
  208. }
  209. /* Input card */
  210. .input-section {
  211. padding: 0 16px;
  212. }
  213. .input-card {
  214. background: rgba(255, 255, 255, 0.03);
  215. border: 1px solid rgba(255, 255, 255, 0.06);
  216. border-radius: 16px;
  217. padding: 24px 20px;
  218. backdrop-filter: blur(10px);
  219. }
  220. .label {
  221. font-size: 13px;
  222. color: rgba(255, 255, 255, 0.5);
  223. margin-bottom: 8px;
  224. display: block;
  225. letter-spacing: 1px;
  226. }
  227. .input-field {
  228. height: 44px;
  229. border: 1px solid rgba(255, 255, 255, 0.08);
  230. border-radius: 10px;
  231. padding: 0 14px;
  232. font-size: 16px;
  233. width: 100%;
  234. color: rgba(255, 255, 255, 0.85);
  235. background: rgba(255, 255, 255, 0.04);
  236. box-sizing: border-box;
  237. }
  238. .mt-16 {
  239. margin-top: 16px;
  240. display: block;
  241. }
  242. /* Date picker */
  243. .date-picker {
  244. width: 100%;
  245. display: block;
  246. }
  247. .date-field {
  248. display: flex;
  249. align-items: center;
  250. justify-content: space-between;
  251. }
  252. .date-val {
  253. font-size: 18px;
  254. color: rgba(255, 255, 255, 0.85);
  255. letter-spacing: 2px;
  256. }
  257. .picker-arrow {
  258. font-size: 10px;
  259. color: rgba(255, 255, 255, 0.3);
  260. }
  261. .question-wrap {
  262. position: relative;
  263. }
  264. .question-input {
  265. width: 100%;
  266. min-height: 80px;
  267. border: 1px solid rgba(255, 255, 255, 0.08);
  268. border-radius: 10px;
  269. padding: 12px 14px 28px;
  270. font-size: 14px;
  271. line-height: 1.5;
  272. box-sizing: border-box;
  273. color: rgba(255, 255, 255, 0.85);
  274. background: rgba(255, 255, 255, 0.04);
  275. }
  276. .char-counter {
  277. position: absolute;
  278. right: 10px;
  279. bottom: 8px;
  280. font-size: 11px;
  281. color: rgba(255, 255, 255, 0.3);
  282. }
  283. .quick-chips {
  284. display: flex;
  285. flex-wrap: wrap;
  286. gap: 8px;
  287. margin: 12px 0 0;
  288. }
  289. .chip {
  290. background: rgba(255, 255, 255, 0.04);
  291. border: 1px solid rgba(255, 255, 255, 0.08);
  292. border-radius: 18px;
  293. padding: 6px 18px;
  294. font-size: 13px;
  295. color: rgba(255, 255, 255, 0.6);
  296. }
  297. .chip.active {
  298. background: rgba(251, 191, 36, 0.12);
  299. border-color: rgba(251, 191, 36, 0.25);
  300. color: #fbbf24;
  301. }
  302. .analyze-btn {
  303. width: 100%;
  304. height: 46px;
  305. background: linear-gradient(135deg, #f59e0b, #d97706);
  306. color: #fff;
  307. border: none;
  308. border-radius: 12px;
  309. font-size: 16px;
  310. font-weight: 600;
  311. line-height: 46px;
  312. letter-spacing: 2px;
  313. margin-top: 4px;
  314. }
  315. /* US-4.1: Upgrade guide banner */
  316. .upgrade-banner {
  317. margin-top: 16px;
  318. text-align: center;
  319. padding: 12px;
  320. background: rgba(251, 191, 36, 0.06);
  321. border: 1px solid rgba(251, 191, 36, 0.1);
  322. border-radius: 10px;
  323. }
  324. .upgrade-banner-text {
  325. font-size: 13px;
  326. color: #fbbf24;
  327. font-weight: 500;
  328. }
  329. </style>