@liujitcn/kratos-taro-app-system 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -0
- package/dist/build.mjs +40 -0
- package/package.json +36 -0
- package/src/api/base/ai_message.ts +377 -0
- package/src/api/base/ai_session.ts +85 -0
- package/src/api/base/ai_tool.ts +23 -0
- package/src/build.ts +13 -0
- package/src/index.ts +14 -0
- package/src/pages.ts +26 -0
- package/src/rpc/base/v1/ai_message.ts +98 -0
- package/src/rpc/base/v1/ai_session.ts +214 -0
- package/src/rpc/base/v1/ai_tool.ts +74 -0
- package/src/rpc/common/v1/enum.ts +71 -0
- package/src/rpc/google/protobuf/timestamp.ts +115 -0
- package/src/types/assets.d.ts +4 -0
- package/src/views/pages/my/my.scss +134 -0
- package/src/views/pages/my/my.tsx +82 -0
- package/src/views/pagesMember/ai/components/Composer.tsx +71 -0
- package/src/views/pagesMember/ai/components/SessionDrawer.tsx +100 -0
- package/src/views/pagesMember/ai/components/WelcomePanel.tsx +75 -0
- package/src/views/pagesMember/ai/components/composer.scss +114 -0
- package/src/views/pagesMember/ai/components/session-drawer.scss +135 -0
- package/src/views/pagesMember/ai/components/welcome-panel.scss +284 -0
- package/src/views/pagesMember/ai/index.scss +130 -0
- package/src/views/pagesMember/ai/index.tsx +945 -0
- package/src/views/pagesMember/ai/stream.ts +152 -0
- package/src/views/pagesMember/profile/profile.scss +135 -0
- package/src/views/pagesMember/profile/profile.tsx +159 -0
- package/src/views/pagesMember/settings/settings.scss +56 -0
- package/src/views/pagesMember/settings/settings.tsx +60 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { AiMessage, AiSession } from '../../../rpc/base/v1/ai_session'
|
|
2
|
+
|
|
3
|
+
/** AI 助手流事件名称。 */
|
|
4
|
+
export type AiStreamEventName = 'delta' | 'finish' | 'error'
|
|
5
|
+
|
|
6
|
+
/** AI 助手流事件负载。 */
|
|
7
|
+
export type AiStreamPayload = {
|
|
8
|
+
session_id: string
|
|
9
|
+
message_id: string
|
|
10
|
+
delta?: string
|
|
11
|
+
messages?: AiMessage[]
|
|
12
|
+
session?: AiSession
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** AI 助手标准化流事件。 */
|
|
16
|
+
export type AiStreamEvent = {
|
|
17
|
+
event: AiStreamEventName
|
|
18
|
+
payload: AiStreamPayload
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type SseOutput = Partial<Record<'data' | 'event' | 'id' | 'retry', unknown>>
|
|
22
|
+
|
|
23
|
+
/** AI 助手流事件消费回调。 */
|
|
24
|
+
export type AiStreamEventHandler = (event: AiStreamEvent) => void
|
|
25
|
+
|
|
26
|
+
/** 增量 SSE 文本解析器。 */
|
|
27
|
+
export type AiEventStreamTextParser = {
|
|
28
|
+
push: (value: unknown) => void
|
|
29
|
+
flush: () => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const STREAM_EVENT_NAMES = new Set<AiStreamEventName>(['delta', 'finish', 'error'])
|
|
33
|
+
|
|
34
|
+
function createSseTextParser(handler: AiStreamEventHandler) {
|
|
35
|
+
let currentItem: SseOutput = {}
|
|
36
|
+
|
|
37
|
+
const dispatchCurrentItem = () => {
|
|
38
|
+
const event = normalizeAiStreamItem(currentItem)
|
|
39
|
+
currentItem = {}
|
|
40
|
+
if (event) handler(event)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const handleLine = (line: string) => {
|
|
44
|
+
if (line === '') {
|
|
45
|
+
dispatchCurrentItem()
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
if (line.startsWith(':')) return
|
|
49
|
+
|
|
50
|
+
const separatorIndex = line.indexOf(':')
|
|
51
|
+
const field = separatorIndex >= 0 ? line.slice(0, separatorIndex) : line
|
|
52
|
+
let value = separatorIndex >= 0 ? line.slice(separatorIndex + 1) : ''
|
|
53
|
+
if (value.startsWith(' ')) value = value.slice(1)
|
|
54
|
+
|
|
55
|
+
if (field === 'data') {
|
|
56
|
+
currentItem.data = currentItem.data === undefined ? value : `${currentItem.data}\n${value}`
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
if (field === 'event' || field === 'id' || field === 'retry') currentItem[field] = value
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { dispatchCurrentItem, handleLine }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 创建可增量消费的 AI 助手 SSE 文本解析器。 */
|
|
66
|
+
export function createAiEventStreamTextParser(
|
|
67
|
+
handler: AiStreamEventHandler,
|
|
68
|
+
): AiEventStreamTextParser {
|
|
69
|
+
const parser = createSseTextParser(handler)
|
|
70
|
+
let buffer = ''
|
|
71
|
+
|
|
72
|
+
const consumeBuffer = (flush = false) => {
|
|
73
|
+
let lineBreakIndex = buffer.indexOf('\n')
|
|
74
|
+
while (lineBreakIndex >= 0) {
|
|
75
|
+
const line = buffer.slice(0, lineBreakIndex).replace(/\r$/, '')
|
|
76
|
+
buffer = buffer.slice(lineBreakIndex + 1)
|
|
77
|
+
parser.handleLine(line)
|
|
78
|
+
lineBreakIndex = buffer.indexOf('\n')
|
|
79
|
+
}
|
|
80
|
+
if (flush && buffer) {
|
|
81
|
+
parser.handleLine(buffer.replace(/\r$/, ''))
|
|
82
|
+
buffer = ''
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
push(value) {
|
|
88
|
+
buffer += String(value ?? '')
|
|
89
|
+
consumeBuffer()
|
|
90
|
+
},
|
|
91
|
+
flush() {
|
|
92
|
+
consumeBuffer(true)
|
|
93
|
+
parser.dispatchCurrentItem()
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isAiStreamEventName(event?: unknown): event is AiStreamEventName {
|
|
99
|
+
return STREAM_EVENT_NAMES.has(String(event ?? '').trim() as AiStreamEventName)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseStreamPayload(data?: unknown): AiStreamPayload | null {
|
|
103
|
+
const rawData = String(data ?? '').trimStart()
|
|
104
|
+
if (!rawData) return null
|
|
105
|
+
try {
|
|
106
|
+
return JSON.parse(rawData) as AiStreamPayload
|
|
107
|
+
} catch {
|
|
108
|
+
return null
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 将原始 SSE 项收敛为业务事件。 */
|
|
113
|
+
export function normalizeAiStreamItem(item?: SseOutput): AiStreamEvent | null {
|
|
114
|
+
if (!item || !isAiStreamEventName(item.event)) return null
|
|
115
|
+
const payload = parseStreamPayload(item.data)
|
|
116
|
+
if (!payload?.session_id || !payload.message_id) return null
|
|
117
|
+
return { event: String(item.event).trim() as AiStreamEventName, payload }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 解析一次性取得的 SSE 文本。 */
|
|
121
|
+
export function parseAiEventStreamText(value: unknown) {
|
|
122
|
+
const events: AiStreamEvent[] = []
|
|
123
|
+
const parser = createAiEventStreamTextParser((event) => events.push(event))
|
|
124
|
+
parser.push(value)
|
|
125
|
+
parser.flush()
|
|
126
|
+
return events
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 读取并解析 AI 助手流式响应。 */
|
|
130
|
+
export async function readAiEventStream(
|
|
131
|
+
readableStream: ReadableStream<Uint8Array>,
|
|
132
|
+
handler: AiStreamEventHandler,
|
|
133
|
+
signal?: AbortSignal,
|
|
134
|
+
) {
|
|
135
|
+
const reader = readableStream.getReader()
|
|
136
|
+
const decoder = new TextDecoder()
|
|
137
|
+
const parser = createAiEventStreamTextParser(handler)
|
|
138
|
+
const abortReader = () => void reader.cancel()
|
|
139
|
+
signal?.addEventListener('abort', abortReader, { once: true })
|
|
140
|
+
try {
|
|
141
|
+
while (!signal?.aborted) {
|
|
142
|
+
const { value, done } = await reader.read()
|
|
143
|
+
if (done) break
|
|
144
|
+
parser.push(decoder.decode(value, { stream: true }))
|
|
145
|
+
}
|
|
146
|
+
parser.push(decoder.decode())
|
|
147
|
+
parser.flush()
|
|
148
|
+
} finally {
|
|
149
|
+
signal?.removeEventListener('abort', abortReader)
|
|
150
|
+
reader.releaseLock()
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
.profile-viewport {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
min-height: 100vh;
|
|
5
|
+
background-color: #f4f4f4;
|
|
6
|
+
background-size: auto 420px;
|
|
7
|
+
background-repeat: no-repeat;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.profile-navbar {
|
|
11
|
+
position: relative;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.profile-navbar__title {
|
|
15
|
+
display: flex;
|
|
16
|
+
height: 40PX;
|
|
17
|
+
align-items: center;
|
|
18
|
+
justify-content: center;
|
|
19
|
+
color: #fff;
|
|
20
|
+
font-size: 16PX;
|
|
21
|
+
font-weight: 500;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.profile-back {
|
|
25
|
+
position: absolute;
|
|
26
|
+
left: 0;
|
|
27
|
+
bottom: 0;
|
|
28
|
+
display: flex;
|
|
29
|
+
width: 40PX;
|
|
30
|
+
height: 40PX;
|
|
31
|
+
align-items: center;
|
|
32
|
+
justify-content: center;
|
|
33
|
+
color: #fff;
|
|
34
|
+
font-size: 30PX;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.profile-avatar {
|
|
38
|
+
display: flex;
|
|
39
|
+
width: 100%;
|
|
40
|
+
height: 260px;
|
|
41
|
+
align-items: center;
|
|
42
|
+
justify-content: center;
|
|
43
|
+
text-align: center;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.profile-avatar__image {
|
|
47
|
+
width: 160px;
|
|
48
|
+
height: 160px;
|
|
49
|
+
border-radius: 50%;
|
|
50
|
+
background-color: #eee;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.profile-avatar__text {
|
|
54
|
+
display: block;
|
|
55
|
+
padding-top: 20px;
|
|
56
|
+
color: #fff;
|
|
57
|
+
font-size: 26px;
|
|
58
|
+
line-height: 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.profile-form {
|
|
62
|
+
background-color: #f4f4f4;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.profile-form__content {
|
|
66
|
+
padding: 0 20px;
|
|
67
|
+
margin: 20px 20px 0;
|
|
68
|
+
border-radius: 10px;
|
|
69
|
+
background-color: #fff;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.profile-form__item {
|
|
73
|
+
display: flex;
|
|
74
|
+
height: 96px;
|
|
75
|
+
padding: 25px 10px;
|
|
76
|
+
border-bottom: 1px solid #ddd;
|
|
77
|
+
background-color: #fff;
|
|
78
|
+
color: #333;
|
|
79
|
+
font-size: 28px;
|
|
80
|
+
line-height: 46px;
|
|
81
|
+
|
|
82
|
+
&:last-child {
|
|
83
|
+
border: 0;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.profile-label {
|
|
88
|
+
width: 180px;
|
|
89
|
+
color: #333;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.profile-account {
|
|
93
|
+
color: #666;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.profile-placeholder {
|
|
97
|
+
color: #808080;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.profile-input {
|
|
101
|
+
display: block;
|
|
102
|
+
flex: 1;
|
|
103
|
+
height: 46px;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.profile-radio {
|
|
107
|
+
margin-right: 20px;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.profile-form__button {
|
|
111
|
+
height: 80px;
|
|
112
|
+
margin: 30px 20px;
|
|
113
|
+
border-radius: 80px;
|
|
114
|
+
color: #fff;
|
|
115
|
+
font-size: 30px;
|
|
116
|
+
line-height: 80px;
|
|
117
|
+
text-align: center;
|
|
118
|
+
background-color: #27ba9b;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.profile-auth-button {
|
|
122
|
+
height: 60px;
|
|
123
|
+
padding: 0 20px;
|
|
124
|
+
margin: 0;
|
|
125
|
+
border: 1px solid #27ba9b;
|
|
126
|
+
border-radius: 30px;
|
|
127
|
+
color: #27ba9b;
|
|
128
|
+
font-size: 26px;
|
|
129
|
+
line-height: 60px;
|
|
130
|
+
background: none;
|
|
131
|
+
|
|
132
|
+
&::after {
|
|
133
|
+
border: 0;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Button,
|
|
3
|
+
Image,
|
|
4
|
+
Input,
|
|
5
|
+
Label,
|
|
6
|
+
Radio,
|
|
7
|
+
RadioGroup,
|
|
8
|
+
Text,
|
|
9
|
+
View,
|
|
10
|
+
} from '@tarojs/components'
|
|
11
|
+
import Taro, { useLoad } from '@tarojs/taro'
|
|
12
|
+
import { useState } from 'react'
|
|
13
|
+
import { defAuthService } from '@liujitcn/kratos-taro-app-core/api/system/auth'
|
|
14
|
+
import { defBaseDictService } from '@liujitcn/kratos-taro-app-core/api/system/base_dict'
|
|
15
|
+
import type { BaseDictForm_DictItem } from '@liujitcn/kratos-taro-app-core/rpc/system/app/v1/base_dict'
|
|
16
|
+
import type { UserProfileForm } from '@liujitcn/kratos-taro-app-core/rpc/system/app/v1/auth'
|
|
17
|
+
import {
|
|
18
|
+
formatSrc,
|
|
19
|
+
navigateToLogin,
|
|
20
|
+
resolveBundledAsset,
|
|
21
|
+
uploadFile,
|
|
22
|
+
useUserStore,
|
|
23
|
+
} from '@liujitcn/kratos-taro-app-core'
|
|
24
|
+
import './profile.scss'
|
|
25
|
+
|
|
26
|
+
const IMG_MAX_SIZE = 1024 * 1024
|
|
27
|
+
const defaultAvatar = resolveBundledAsset('static/images/avatar.png')
|
|
28
|
+
const navigatorBackground = resolveBundledAsset('static/images/navigator_bg.png')
|
|
29
|
+
|
|
30
|
+
/** 用户个人资料编辑页。 */
|
|
31
|
+
export default function ProfilePage() {
|
|
32
|
+
const [userInfo, setUserInfo] = useState<UserProfileForm>({
|
|
33
|
+
user_name: '',
|
|
34
|
+
nick_name: '',
|
|
35
|
+
gender: 0,
|
|
36
|
+
phone: '',
|
|
37
|
+
avatar: '',
|
|
38
|
+
})
|
|
39
|
+
const [genderList, setGenderList] = useState<BaseDictForm_DictItem[]>([])
|
|
40
|
+
const ensureAuthenticated = useUserStore((state) => state.ensureAuthenticated)
|
|
41
|
+
const safeTop = Taro.getWindowInfo().safeArea?.top || 0
|
|
42
|
+
|
|
43
|
+
const requireAuth = () => {
|
|
44
|
+
if (ensureAuthenticated()) return true
|
|
45
|
+
navigateToLogin()
|
|
46
|
+
return false
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const loadData = async () => {
|
|
50
|
+
const [profile, gender] = await Promise.all([
|
|
51
|
+
useUserStore.getState().getUserProfile(),
|
|
52
|
+
defBaseDictService.GetBaseDict({ value: 'base_user_gender' }),
|
|
53
|
+
])
|
|
54
|
+
setUserInfo(profile)
|
|
55
|
+
setGenderList(gender.items || [])
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
useLoad(() => {
|
|
59
|
+
if (requireAuth()) void loadData()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const uploadAvatar = async (filePath: string) => {
|
|
63
|
+
if (!requireAuth()) return
|
|
64
|
+
try {
|
|
65
|
+
const fileInfo = await uploadFile('avatar', filePath)
|
|
66
|
+
const nextProfile = { ...userInfo, avatar: fileInfo.url }
|
|
67
|
+
await defAuthService.UpdateUserProfile(nextProfile)
|
|
68
|
+
setUserInfo(nextProfile)
|
|
69
|
+
await useUserStore.getState().getUserProfile()
|
|
70
|
+
await Taro.showToast({ icon: 'success', title: '更新成功' })
|
|
71
|
+
} catch {
|
|
72
|
+
await Taro.showToast({ icon: 'error', title: '上传头像失败' })
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const onAvatarChange = async () => {
|
|
77
|
+
if (!requireAuth()) return
|
|
78
|
+
const selected =
|
|
79
|
+
process.env.TARO_ENV === 'weapp'
|
|
80
|
+
? await Taro.chooseMedia({ count: 1, mediaType: ['image'] })
|
|
81
|
+
: await Taro.chooseImage({ count: 1 })
|
|
82
|
+
const file = selected.tempFiles[0]
|
|
83
|
+
const filePath = 'tempFilePath' in file ? file.tempFilePath : file.path
|
|
84
|
+
if (file.size > IMG_MAX_SIZE) {
|
|
85
|
+
await Taro.showToast({ title: '请上传小于1M的照片', icon: 'none', duration: 1500 })
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
await uploadAvatar(filePath)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const onGetPhoneNumber = async (event: { detail: { errMsg?: string; code?: string } }) => {
|
|
92
|
+
if (event.detail.errMsg !== 'getPhoneNumber:ok' || !requireAuth()) return
|
|
93
|
+
const response = await defAuthService.BindUserPhone({ code: event.detail.code || '' })
|
|
94
|
+
setUserInfo((current) => ({ ...current, phone: response.phone }))
|
|
95
|
+
await useUserStore.getState().getUserProfile()
|
|
96
|
+
await Taro.showToast({ icon: 'success', title: '授权成功' })
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const onSubmit = async () => {
|
|
100
|
+
if (!requireAuth()) return
|
|
101
|
+
await defAuthService.UpdateUserProfile(userInfo)
|
|
102
|
+
await useUserStore.getState().getUserProfile()
|
|
103
|
+
await Taro.showToast({ icon: 'success', title: '保存成功' })
|
|
104
|
+
setTimeout(() => void Taro.navigateBack(), 400)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<View className='profile-viewport' style={{ backgroundImage: `url(${navigatorBackground})` }}>
|
|
109
|
+
<View className='profile-navbar' style={{ paddingTop: `${safeTop}px` }}>
|
|
110
|
+
<View className='profile-back' onClick={() => void Taro.navigateBack()}>‹</View>
|
|
111
|
+
<View className='profile-navbar__title'>个人信息</View>
|
|
112
|
+
</View>
|
|
113
|
+
<View className='profile-avatar'>
|
|
114
|
+
<View className='profile-avatar__content' onClick={() => void onAvatarChange()}>
|
|
115
|
+
<Image className='profile-avatar__image' src={userInfo.avatar ? formatSrc(userInfo.avatar) : defaultAvatar} mode='aspectFill' />
|
|
116
|
+
<Text className='profile-avatar__text'>点击修改头像</Text>
|
|
117
|
+
</View>
|
|
118
|
+
</View>
|
|
119
|
+
<View className='profile-form'>
|
|
120
|
+
<View className='profile-form__content'>
|
|
121
|
+
{userInfo.user_name ? (
|
|
122
|
+
<View className='profile-form__item'>
|
|
123
|
+
<Text className='profile-label'>账号</Text>
|
|
124
|
+
<Text className='profile-account profile-placeholder'>{userInfo.user_name}</Text>
|
|
125
|
+
</View>
|
|
126
|
+
) : null}
|
|
127
|
+
{process.env.TARO_ENV === 'weapp' ? (
|
|
128
|
+
<View className='profile-form__item'>
|
|
129
|
+
<Text className='profile-label'>手机号</Text>
|
|
130
|
+
<View className='profile-input'>
|
|
131
|
+
{userInfo.phone ? (
|
|
132
|
+
<Text className='profile-account'>{userInfo.phone}</Text>
|
|
133
|
+
) : (
|
|
134
|
+
<Button className='profile-auth-button' openType='getPhoneNumber' onGetPhoneNumber={onGetPhoneNumber}>微信授权手机号</Button>
|
|
135
|
+
)}
|
|
136
|
+
</View>
|
|
137
|
+
</View>
|
|
138
|
+
) : null}
|
|
139
|
+
<View className='profile-form__item'>
|
|
140
|
+
<Text className='profile-label'>昵称</Text>
|
|
141
|
+
<Input className='profile-input' placeholder='请填写昵称' value={userInfo.nick_name} onInput={(event) => setUserInfo((current) => ({ ...current, nick_name: event.detail.value }))} />
|
|
142
|
+
</View>
|
|
143
|
+
<View className='profile-form__item'>
|
|
144
|
+
<Text className='profile-label'>性别</Text>
|
|
145
|
+
<RadioGroup onChange={(event) => setUserInfo((current) => ({ ...current, gender: Number(event.detail.value) }))}>
|
|
146
|
+
{genderList.map((item) => (
|
|
147
|
+
<Label className='profile-radio' key={item.value}>
|
|
148
|
+
<Radio value={item.value} color='#27ba9b' checked={userInfo.gender === Number(item.value)} />
|
|
149
|
+
{item.label}
|
|
150
|
+
</Label>
|
|
151
|
+
))}
|
|
152
|
+
</RadioGroup>
|
|
153
|
+
</View>
|
|
154
|
+
</View>
|
|
155
|
+
<Button className='profile-form__button' onClick={() => void onSubmit()}>保 存</Button>
|
|
156
|
+
</View>
|
|
157
|
+
</View>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
.settings-viewport {
|
|
2
|
+
min-height: 100vh;
|
|
3
|
+
padding: 20px;
|
|
4
|
+
background-color: #f4f4f4;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
.settings-list {
|
|
8
|
+
padding: 0 20px;
|
|
9
|
+
margin-bottom: 20px;
|
|
10
|
+
border-radius: 10px;
|
|
11
|
+
background-color: #fff;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.settings-item {
|
|
15
|
+
position: relative;
|
|
16
|
+
padding-left: 10px;
|
|
17
|
+
border-top: 1px solid #ddd;
|
|
18
|
+
border-radius: 0;
|
|
19
|
+
color: #333;
|
|
20
|
+
font-size: 30px;
|
|
21
|
+
line-height: 90px;
|
|
22
|
+
text-align: left;
|
|
23
|
+
background-color: #fff;
|
|
24
|
+
|
|
25
|
+
&:first-child {
|
|
26
|
+
border: 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
&::after {
|
|
30
|
+
border: 0;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.settings-arrow::before {
|
|
35
|
+
content: '›';
|
|
36
|
+
position: absolute;
|
|
37
|
+
top: 50%;
|
|
38
|
+
right: 5px;
|
|
39
|
+
color: #ccc;
|
|
40
|
+
font-size: 36px;
|
|
41
|
+
transform: translateY(-50%);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.settings-action {
|
|
45
|
+
margin-top: 40px;
|
|
46
|
+
color: #333;
|
|
47
|
+
font-size: 32px;
|
|
48
|
+
line-height: 90px;
|
|
49
|
+
text-align: center;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.settings-button {
|
|
53
|
+
margin-bottom: 20px;
|
|
54
|
+
border-radius: 10px;
|
|
55
|
+
background-color: #fff;
|
|
56
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Button, View } from '@tarojs/components'
|
|
2
|
+
import Taro, { useLoad } from '@tarojs/taro'
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
navigateToLogin,
|
|
6
|
+
useUserStore,
|
|
7
|
+
} from '@liujitcn/kratos-taro-app-core'
|
|
8
|
+
import './settings.scss'
|
|
9
|
+
|
|
10
|
+
/** 应用设置页。 */
|
|
11
|
+
export default function SettingsPage() {
|
|
12
|
+
const [logoutLoading, setLogoutLoading] = useState(false)
|
|
13
|
+
const authenticated = useUserStore((state) => state.isAuthenticated())
|
|
14
|
+
const ensureAuthenticated = useUserStore((state) => state.ensureAuthenticated)
|
|
15
|
+
const logout = useUserStore((state) => state.logout)
|
|
16
|
+
|
|
17
|
+
useLoad(() => {
|
|
18
|
+
if (process.env.TARO_ENV !== 'weapp' && !ensureAuthenticated()) navigateToLogin()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const onLogout = async () => {
|
|
22
|
+
if (logoutLoading) return
|
|
23
|
+
const result = await Taro.showModal({ content: '是否退出登录?', confirmColor: '#27BA9B' })
|
|
24
|
+
if (!result.confirm) return
|
|
25
|
+
setLogoutLoading(true)
|
|
26
|
+
try {
|
|
27
|
+
await logout()
|
|
28
|
+
await Taro.navigateBack()
|
|
29
|
+
} catch {
|
|
30
|
+
await Taro.showToast({ icon: 'none', title: '退出登录失败' })
|
|
31
|
+
} finally {
|
|
32
|
+
setLogoutLoading(false)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<View className='settings-viewport'>
|
|
38
|
+
{process.env.TARO_ENV === 'weapp' ? (
|
|
39
|
+
<View className='settings-list'>
|
|
40
|
+
<Button className='settings-item settings-arrow' hoverClass='none' openType='openSetting'>
|
|
41
|
+
授权管理
|
|
42
|
+
</Button>
|
|
43
|
+
<Button className='settings-item settings-arrow' hoverClass='none' openType='feedback'>
|
|
44
|
+
问题反馈
|
|
45
|
+
</Button>
|
|
46
|
+
<Button className='settings-item settings-arrow' hoverClass='none' openType='contact'>
|
|
47
|
+
联系我们
|
|
48
|
+
</Button>
|
|
49
|
+
</View>
|
|
50
|
+
) : null}
|
|
51
|
+
{authenticated ? (
|
|
52
|
+
<View className='settings-action'>
|
|
53
|
+
<View className='settings-button' onClick={() => void onLogout()}>
|
|
54
|
+
退出登录
|
|
55
|
+
</View>
|
|
56
|
+
</View>
|
|
57
|
+
) : null}
|
|
58
|
+
</View>
|
|
59
|
+
)
|
|
60
|
+
}
|