@liujitcn/kratos-uni-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.
@@ -0,0 +1,190 @@
1
+ import type { AiMessage, AiSession } from '../../../rpc/base/v1/ai_session'
2
+
3
+ /** AI 助手 direct stream SSE 事件名称。 */
4
+ export type AiStreamEventName = 'delta' | 'finish' | 'error'
5
+
6
+ /** AI 助手 direct stream 事件负载。 */
7
+ export type AiStreamPayload = {
8
+ /** 会话 ID。 */
9
+ session_id: string
10
+ /** 后端单轮消息 ID,用于关联当前轮次。 */
11
+ message_id: string
12
+ /** 本次新增文本分片。 */
13
+ delta?: string
14
+ /** 流式完成后的最终消息列表。 */
15
+ messages?: AiMessage[]
16
+ /** 流式完成后的最新会话。 */
17
+ session?: AiSession
18
+ }
19
+
20
+ /** AI 助手 direct stream 标准化事件。 */
21
+ export type AiStreamEvent = {
22
+ /** SSE 事件名称。 */
23
+ event: AiStreamEventName
24
+ /** 已解析的 JSON 负载。 */
25
+ payload: AiStreamPayload
26
+ }
27
+
28
+ /** 读取到的原始 SSE 字段结构。 */
29
+ type SseOutput = Partial<Record<'data' | 'event' | 'id' | 'retry', unknown>>
30
+
31
+ /** AI 助手事件流消费回调。 */
32
+ export type AiStreamEventHandler = (event: AiStreamEvent) => void
33
+
34
+ /** 增量 SSE 文本解析器。 */
35
+ export type AiEventStreamTextParser = {
36
+ push: (value: unknown) => void
37
+ flush: () => void
38
+ }
39
+
40
+ const STREAM_EVENT_NAMES = new Set<AiStreamEventName>(['delta', 'finish', 'error'])
41
+
42
+ function createSseTextParser(handler: AiStreamEventHandler) {
43
+ let currentItem: SseOutput = {}
44
+
45
+ const dispatchCurrentItem = () => {
46
+ const event = normalizeAiStreamItem(currentItem)
47
+ currentItem = {}
48
+ if (event) {
49
+ handler(event)
50
+ }
51
+ }
52
+
53
+ const handleLine = (line: string) => {
54
+ if (line === '') {
55
+ dispatchCurrentItem()
56
+ return
57
+ }
58
+ if (line.startsWith(':')) {
59
+ return
60
+ }
61
+
62
+ const separatorIndex = line.indexOf(':')
63
+ const field = separatorIndex >= 0 ? line.slice(0, separatorIndex) : line
64
+ let value = separatorIndex >= 0 ? line.slice(separatorIndex + 1) : ''
65
+ if (value.startsWith(' ')) {
66
+ value = value.slice(1)
67
+ }
68
+
69
+ if (field === 'data') {
70
+ currentItem.data = currentItem.data === undefined ? value : `${currentItem.data}\n${value}`
71
+ return
72
+ }
73
+ if (field === 'event' || field === 'id' || field === 'retry') {
74
+ currentItem[field] = value
75
+ }
76
+ }
77
+
78
+ return { dispatchCurrentItem, handleLine }
79
+ }
80
+
81
+ /** 创建可增量消费的 AI 助手 SSE 文本解析器。 */
82
+ export function createAiEventStreamTextParser(
83
+ handler: AiStreamEventHandler,
84
+ ): AiEventStreamTextParser {
85
+ const parser = createSseTextParser(handler)
86
+ let buffer = ''
87
+
88
+ const consumeBuffer = (flush = false) => {
89
+ let lineBreakIndex = buffer.indexOf('\n')
90
+ while (lineBreakIndex >= 0) {
91
+ const line = buffer.slice(0, lineBreakIndex).replace(/\r$/, '')
92
+ buffer = buffer.slice(lineBreakIndex + 1)
93
+ parser.handleLine(line)
94
+ lineBreakIndex = buffer.indexOf('\n')
95
+ }
96
+ if (flush && buffer) {
97
+ parser.handleLine(buffer.replace(/\r$/, ''))
98
+ buffer = ''
99
+ }
100
+ }
101
+
102
+ return {
103
+ push(value: unknown) {
104
+ buffer += String(value ?? '')
105
+ consumeBuffer()
106
+ },
107
+ flush() {
108
+ consumeBuffer(true)
109
+ parser.dispatchCurrentItem()
110
+ },
111
+ }
112
+ }
113
+
114
+ /** 判断 SSE 事件名称是否为 AI 助手 direct stream 支持的事件。 */
115
+ function isAiStreamEventName(event?: unknown): event is AiStreamEventName {
116
+ return STREAM_EVENT_NAMES.has(String(event ?? '').trim() as AiStreamEventName)
117
+ }
118
+
119
+ /** 解析 SSE data 字段,兼容前导空格和空消息。 */
120
+ function parseStreamPayload(data?: unknown): AiStreamPayload | null {
121
+ const rawData = String(data ?? '').trimStart()
122
+ if (!rawData) {
123
+ return null
124
+ }
125
+
126
+ try {
127
+ return JSON.parse(rawData) as AiStreamPayload
128
+ } catch {
129
+ return null
130
+ }
131
+ }
132
+
133
+ /** 将原始 SSE 项收敛为业务事件,避免页面直接处理字符串 JSON。 */
134
+ export function normalizeAiStreamItem(item?: SseOutput): AiStreamEvent | null {
135
+ if (!item || !isAiStreamEventName(item.event)) {
136
+ return null
137
+ }
138
+
139
+ const payload = parseStreamPayload(item.data)
140
+ if (!payload?.session_id || !payload.message_id) {
141
+ return null
142
+ }
143
+
144
+ return {
145
+ event: String(item.event).trim() as AiStreamEventName,
146
+ payload,
147
+ }
148
+ }
149
+
150
+ /** 解析非流式客户端一次性拿到的 SSE 文本。 */
151
+ export function parseAiEventStreamText(value: unknown) {
152
+ const events: AiStreamEvent[] = []
153
+ const parser = createAiEventStreamTextParser((event) => events.push(event))
154
+ parser.push(value)
155
+ parser.flush()
156
+ return events
157
+ }
158
+
159
+ /** 读取并解析 AI 助手 direct stream,支持同一页面同时消费多条会话流。 */
160
+ export async function readAiEventStream(
161
+ readableStream: ReadableStream<Uint8Array>,
162
+ handler: AiStreamEventHandler,
163
+ signal?: AbortSignal,
164
+ ) {
165
+ const reader = readableStream.getReader()
166
+ const decoder = new TextDecoder()
167
+ const parser = createAiEventStreamTextParser(handler)
168
+
169
+ const abortReader = () => {
170
+ void reader.cancel()
171
+ }
172
+ signal?.addEventListener('abort', abortReader, { once: true })
173
+ try {
174
+ while (true) {
175
+ if (signal?.aborted) {
176
+ break
177
+ }
178
+ const { value, done } = await reader.read()
179
+ if (done) {
180
+ break
181
+ }
182
+ parser.push(decoder.decode(value, { stream: true }))
183
+ }
184
+ parser.push(decoder.decode())
185
+ parser.flush()
186
+ } finally {
187
+ signal?.removeEventListener('abort', abortReader)
188
+ reader.releaseLock()
189
+ }
190
+ }
@@ -0,0 +1,393 @@
1
+ <script setup lang="ts">
2
+ import { defAuthService } from '@liujitcn/kratos-uni-app-core/api/system/auth'
3
+ import { useUserStore } from '@liujitcn/kratos-uni-app-core/stores'
4
+ import type { UserProfileForm } from '@liujitcn/kratos-uni-app-core/rpc/system/app/v1/auth'
5
+ import { onLoad } from '@dcloudio/uni-app'
6
+ import { ref } from 'vue'
7
+ import type { BaseDictForm_DictItem } from '@liujitcn/kratos-uni-app-core/rpc/system/app/v1/base_dict'
8
+ import { defBaseDictService } from '@liujitcn/kratos-uni-app-core/api/system/base_dict'
9
+ import { formatSrc } from '@liujitcn/kratos-uni-app-core/utils/index'
10
+ import { uploadFile } from '@liujitcn/kratos-uni-app-core/utils/file'
11
+ import { navigateToLogin } from '@liujitcn/kratos-uni-app-core/utils/navigation'
12
+ import defaultAvatar from '@liujitcn/kratos-uni-app-core/static/images/avatar.png'
13
+ import navigatorBackground from '@liujitcn/kratos-uni-app-core/static/images/navigator_bg.png'
14
+
15
+ const userStore = useUserStore()
16
+
17
+ // 获取屏幕边界到安全区域距离
18
+ const { safeAreaInsets } = uni.getSystemInfoSync()
19
+
20
+ const imgMaxSize = ref(1024 * 1024)
21
+
22
+ // 获取个人信息,修改个人信息需提供初始值
23
+ const userInfo = ref({} as UserProfileForm)
24
+ const syncUserStoreProfile = (profile: UserProfileForm) => {
25
+ if (!userStore.userInfo) {
26
+ return
27
+ }
28
+
29
+ userStore.userInfo.user_name = profile.user_name
30
+ userStore.userInfo.nick_name = profile.nick_name
31
+ userStore.userInfo.gender = profile.gender
32
+ userStore.userInfo.phone = profile.phone
33
+ userStore.userInfo.avatar = profile.avatar
34
+ }
35
+
36
+ const getUserData = async () => {
37
+ const res = await defAuthService.GetUserProfile({})
38
+ userInfo.value = res
39
+ // 同步 Store 的头像和昵称,用于我的页面展示
40
+ syncUserStoreProfile(res)
41
+ }
42
+
43
+ const genderList = ref<BaseDictForm_DictItem[]>([])
44
+
45
+ const getDictData = async () => {
46
+ const genderCode = 'base_user_gender'
47
+ const res = await defBaseDictService.GetBaseDict({
48
+ value: genderCode,
49
+ })
50
+ genderList.value = res.items || []
51
+ }
52
+
53
+ onLoad(() => {
54
+ if (!userStore.ensureAuthenticated()) {
55
+ navigateToLogin()
56
+ return
57
+ }
58
+
59
+ Promise.all([getUserData(), getDictData()])
60
+ })
61
+ // 修改头像
62
+ const onAvatarChange = async () => {
63
+ if (!userStore.ensureAuthenticated()) {
64
+ navigateToLogin()
65
+ return
66
+ }
67
+
68
+ // 调用拍照/选择图片
69
+ // 选择图片条件编译
70
+ // #ifdef H5 || APP-PLUS
71
+ // 微信小程序从基础库 2.21.0 开始, wx.chooseImage 停止维护,请使用 uni.chooseMedia 代替
72
+ uni.chooseImage({
73
+ count: 1,
74
+ success: async (res: any) => {
75
+ const { path, size } = res.tempFiles[0]
76
+ if (size > imgMaxSize.value) {
77
+ await uni.showToast({
78
+ title: '请上传小于1M的照片',
79
+ icon: 'none',
80
+ duration: 1500,
81
+ })
82
+ return
83
+ }
84
+ // 上传
85
+ await uploadAvatar(path)
86
+ },
87
+ })
88
+ // #endif
89
+
90
+ // #ifdef MP-WEIXIN
91
+ // uni.chooseMedia 仅支持微信小程序端
92
+ uni.chooseMedia({
93
+ // 文件个数
94
+ count: 1,
95
+ // 文件类型
96
+ mediaType: ['image'],
97
+ success: async (res: any) => {
98
+ // 本地路径
99
+ const { tempFilePath, size } = res.tempFiles[0]
100
+ if (size > imgMaxSize.value) {
101
+ await uni.showToast({
102
+ title: '请上传小于1M的照片',
103
+ icon: 'none',
104
+ duration: 1500,
105
+ })
106
+ return
107
+ }
108
+ await uploadAvatar(tempFilePath)
109
+ },
110
+ })
111
+ // #endif
112
+ }
113
+
114
+ // 上传头像并同步个人资料与用户 Store。
115
+ const uploadAvatar = async (file: string) => {
116
+ if (!userStore.ensureAuthenticated()) {
117
+ navigateToLogin()
118
+ return
119
+ }
120
+
121
+ try {
122
+ const fileInfo = await uploadFile('avatar', file)
123
+ userInfo.value.avatar = fileInfo.url
124
+ await defAuthService.UpdateUserProfile(userInfo.value)
125
+ syncUserStoreProfile(userInfo.value)
126
+ await uni.showToast({ icon: 'success', title: '更新成功' })
127
+ } catch {
128
+ await uni.showToast({ icon: 'error', title: '上传头像失败' })
129
+ }
130
+ }
131
+
132
+ // 修改性别
133
+ const onGenderChange: UniHelper.RadioGroupOnChange = (ev) => {
134
+ userInfo.value.gender = Number(ev.detail.value)
135
+ }
136
+
137
+ // #ifdef MP-WEIXIN
138
+ // 新增授权手机号处理
139
+ const onGetPhoneNumber: UniHelper.ButtonOnGetphonenumber = async (e) => {
140
+ if (e.detail.errMsg !== 'getPhoneNumber:ok') return
141
+ if (!userStore.ensureAuthenticated()) {
142
+ navigateToLogin()
143
+ return
144
+ }
145
+
146
+ const res = await defAuthService.BindUserPhone({ code: e.detail.code || '' })
147
+ userInfo.value.phone = res.phone
148
+ syncUserStoreProfile(userInfo.value)
149
+ await uni.showToast({ icon: 'success', title: '授权成功' })
150
+ }
151
+
152
+ // #endif
153
+
154
+ // 点击保存提交表单
155
+ const onSubmit = async () => {
156
+ if (!userStore.ensureAuthenticated()) {
157
+ navigateToLogin()
158
+ return
159
+ }
160
+
161
+ const { nick_name, gender } = userInfo.value
162
+ await defAuthService.UpdateUserProfile({
163
+ nick_name: nick_name,
164
+ gender: gender,
165
+ avatar: userInfo.value.avatar,
166
+ phone: userInfo.value.phone,
167
+ user_name: userInfo.value.user_name,
168
+ })
169
+ // 更新Store昵称
170
+ syncUserStoreProfile(userInfo.value)
171
+ await uni.showToast({ icon: 'success', title: '保存成功' })
172
+ setTimeout(() => {
173
+ uni.navigateBack()
174
+ }, 400)
175
+ }
176
+ </script>
177
+
178
+ <template>
179
+ <view class="viewport" :style="{ backgroundImage: `url(${navigatorBackground})` }">
180
+ <!-- 导航栏 -->
181
+ <view class="navbar" :style="{ paddingTop: safeAreaInsets?.top + 'px' }">
182
+ <navigator open-type="navigateBack" class="back icon-left" hover-class="none"></navigator>
183
+ <view class="title">个人信息</view>
184
+ </view>
185
+ <view class="avatar">
186
+ <view @tap="onAvatarChange" class="avatar-content">
187
+ <image
188
+ v-if="userInfo?.avatar"
189
+ class="image"
190
+ :src="formatSrc(userInfo?.avatar)"
191
+ mode="aspectFill"
192
+ />
193
+ <image v-else class="image" :src="defaultAvatar" mode="aspectFill"></image>
194
+ <text class="text">点击修改头像</text>
195
+ </view>
196
+ </view>
197
+ <!-- 表单 -->
198
+ <view class="form">
199
+ <!-- 表单内容 -->
200
+ <view class="form-content">
201
+ <view class="form-item" v-if="userInfo?.user_name">
202
+ <text class="label">账号</text>
203
+ <text class="account placeholder">{{ userInfo?.user_name }}</text>
204
+ </view>
205
+ <!-- #ifdef MP-WEIXIN -->
206
+ <!-- 手机号 -->
207
+ <view class="form-item">
208
+ <text class="label">手机号</text>
209
+ <view class="input">
210
+ <text v-if="userInfo.phone" class="account">{{ userInfo.phone }}</text>
211
+ <button
212
+ v-else
213
+ class="auth-button"
214
+ open-type="getPhoneNumber"
215
+ @getphonenumber="onGetPhoneNumber"
216
+ >
217
+ 微信授权手机号
218
+ </button>
219
+ </view>
220
+ </view>
221
+ <!-- #endif -->
222
+ <view class="form-item">
223
+ <text class="label">昵称</text>
224
+ <input class="input" type="text" placeholder="请填写昵称" v-model="userInfo.nick_name" />
225
+ </view>
226
+ <view class="form-item">
227
+ <text class="label">性别</text>
228
+ <radio-group @change="onGenderChange">
229
+ <label class="radio" v-for="(item, index) in genderList" :key="index">
230
+ <radio
231
+ :value="item.value"
232
+ color="#27ba9b"
233
+ :checked="userInfo?.gender === Number(item.value)"
234
+ />
235
+ {{ item.label }}
236
+ </label>
237
+ </radio-group>
238
+ </view>
239
+ </view>
240
+ <!-- 提交按钮 -->
241
+ <button @tap="onSubmit" class="form-button">保 存</button>
242
+ </view>
243
+ </view>
244
+ </template>
245
+
246
+ <style lang="scss">
247
+ page {
248
+ background-color: #f4f4f4;
249
+ }
250
+
251
+ .viewport {
252
+ display: flex;
253
+ flex-direction: column;
254
+ height: 100%;
255
+ background-size: auto 420rpx;
256
+ background-repeat: no-repeat;
257
+ }
258
+
259
+ // 导航栏
260
+ .navbar {
261
+ position: relative;
262
+
263
+ .title {
264
+ height: 40px;
265
+ display: flex;
266
+ justify-content: center;
267
+ align-items: center;
268
+ font-size: 16px;
269
+ font-weight: 500;
270
+ color: #fff;
271
+ }
272
+
273
+ .back {
274
+ position: absolute;
275
+ height: 40px;
276
+ width: 40px;
277
+ left: 0;
278
+ font-size: 20px;
279
+ color: #fff;
280
+ display: flex;
281
+ justify-content: center;
282
+ align-items: center;
283
+
284
+ &::before {
285
+ content: '‹';
286
+ }
287
+ }
288
+ }
289
+
290
+ // 头像
291
+ .avatar {
292
+ text-align: center;
293
+ width: 100%;
294
+ height: 260rpx;
295
+ display: flex;
296
+ flex-direction: column;
297
+ justify-content: center;
298
+ align-items: center;
299
+
300
+ .image {
301
+ width: 160rpx;
302
+ height: 160rpx;
303
+ border-radius: 50%;
304
+ background-color: #eee;
305
+ }
306
+
307
+ .text {
308
+ display: block;
309
+ padding-top: 20rpx;
310
+ line-height: 1;
311
+ font-size: 26rpx;
312
+ color: #fff;
313
+ }
314
+ }
315
+
316
+ // 表单
317
+ .form {
318
+ background-color: #f4f4f4;
319
+
320
+ &-content {
321
+ margin: 20rpx 20rpx 0;
322
+ padding: 0 20rpx;
323
+ border-radius: 10rpx;
324
+ background-color: #fff;
325
+ }
326
+
327
+ &-item {
328
+ display: flex;
329
+ height: 96rpx;
330
+ line-height: 46rpx;
331
+ padding: 25rpx 10rpx;
332
+ background-color: #fff;
333
+ font-size: 28rpx;
334
+ border-bottom: 1rpx solid #ddd;
335
+
336
+ &:last-child {
337
+ border: none;
338
+ }
339
+
340
+ .label {
341
+ width: 180rpx;
342
+ color: #333;
343
+ }
344
+
345
+ .account {
346
+ color: #666;
347
+ }
348
+
349
+ .input {
350
+ flex: 1;
351
+ display: block;
352
+ height: 46rpx;
353
+ }
354
+
355
+ .radio {
356
+ margin-right: 20rpx;
357
+ }
358
+
359
+ .picker {
360
+ flex: 1;
361
+ }
362
+ .placeholder {
363
+ color: #808080;
364
+ }
365
+ }
366
+
367
+ &-button {
368
+ height: 80rpx;
369
+ text-align: center;
370
+ line-height: 80rpx;
371
+ margin: 30rpx 20rpx;
372
+ color: #fff;
373
+ border-radius: 80rpx;
374
+ font-size: 30rpx;
375
+ background-color: #27ba9b;
376
+ }
377
+ }
378
+ .auth-button {
379
+ height: 60rpx;
380
+ line-height: 60rpx;
381
+ margin: 0;
382
+ padding: 0 20rpx;
383
+ font-size: 26rpx;
384
+ color: #27ba9b;
385
+ border: 1rpx solid #27ba9b;
386
+ border-radius: 30rpx;
387
+ background: none;
388
+
389
+ &::after {
390
+ border: none;
391
+ }
392
+ }
393
+ </style>
@@ -0,0 +1,129 @@
1
+ <script setup lang="ts">
2
+ import { useUserStore } from '@liujitcn/kratos-uni-app-core/stores'
3
+ import { onLoad } from '@dcloudio/uni-app'
4
+ import { ref } from 'vue'
5
+ import { navigateToLogin } from '@liujitcn/kratos-uni-app-core/utils/navigation'
6
+
7
+ const userStore = useUserStore()
8
+ const logoutLoading = ref(false)
9
+
10
+ // #ifndef MP-WEIXIN
11
+ // 非微信小程序端未登录时没有可用设置项,直接引导登录以避免显示空白页面。
12
+ onLoad(() => {
13
+ if (!userStore.ensureAuthenticated()) {
14
+ navigateToLogin()
15
+ }
16
+ })
17
+ // #endif
18
+
19
+ // 退出登录
20
+ const onLogout = () => {
21
+ if (logoutLoading.value) {
22
+ return
23
+ }
24
+ // 模态弹窗
25
+ uni.showModal({
26
+ content: '是否退出登录?',
27
+ confirmColor: '#27BA9B',
28
+ success: async (res) => {
29
+ if (!res.confirm) {
30
+ return
31
+ }
32
+
33
+ logoutLoading.value = true
34
+ try {
35
+ // 先完成退出和本地登录态清理,再返回个人中心,避免 onShow 读取到旧登录态。
36
+ await userStore.logout()
37
+ uni.navigateBack()
38
+ } catch (error) {
39
+ await uni.showToast({
40
+ icon: 'none',
41
+ title: '退出登录失败',
42
+ })
43
+ } finally {
44
+ logoutLoading.value = false
45
+ }
46
+ },
47
+ })
48
+ }
49
+ </script>
50
+
51
+ <template>
52
+ <view class="viewport">
53
+ <!-- #ifdef MP-WEIXIN -->
54
+ <!-- 列表2 -->
55
+ <view class="list">
56
+ <button hover-class="none" class="item arrow" open-type="openSetting">授权管理</button>
57
+ <button hover-class="none" class="item arrow" open-type="feedback">问题反馈</button>
58
+ <button hover-class="none" class="item arrow" open-type="contact">联系我们</button>
59
+ </view>
60
+ <!-- #endif -->
61
+ <!-- 操作按钮 -->
62
+ <view class="action" v-if="userStore.isAuthenticated()">
63
+ <view @tap="onLogout" class="button">退出登录</view>
64
+ </view>
65
+ </view>
66
+ </template>
67
+
68
+ <style lang="scss">
69
+ page {
70
+ background-color: #f4f4f4;
71
+ }
72
+
73
+ .viewport {
74
+ padding: 20rpx;
75
+ }
76
+
77
+ /* 列表 */
78
+ .list {
79
+ padding: 0 20rpx;
80
+ background-color: #fff;
81
+ margin-bottom: 20rpx;
82
+ border-radius: 10rpx;
83
+ .item {
84
+ line-height: 90rpx;
85
+ padding-left: 10rpx;
86
+ font-size: 30rpx;
87
+ color: #333;
88
+ border-top: 1rpx solid #ddd;
89
+ position: relative;
90
+ text-align: left;
91
+ border-radius: 0;
92
+ background-color: #fff;
93
+ &::after {
94
+ width: auto;
95
+ height: auto;
96
+ left: auto;
97
+ border: none;
98
+ }
99
+ &:first-child {
100
+ border: none;
101
+ }
102
+ &::after {
103
+ right: 5rpx;
104
+ }
105
+ }
106
+ .arrow::after {
107
+ content: '›';
108
+ position: absolute;
109
+ top: 50%;
110
+ color: #ccc;
111
+ font-size: 36rpx;
112
+ transform: translateY(-50%);
113
+ }
114
+ }
115
+
116
+ /* 操作按钮 */
117
+ .action {
118
+ text-align: center;
119
+ line-height: 90rpx;
120
+ margin-top: 40rpx;
121
+ font-size: 32rpx;
122
+ color: #333;
123
+ .button {
124
+ background-color: #fff;
125
+ margin-bottom: 20rpx;
126
+ border-radius: 10rpx;
127
+ }
128
+ }
129
+ </style>