@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.
- package/README.md +31 -0
- package/dist/index.mjs +93 -0
- package/package.json +38 -0
- package/src/api/base/ai_message.ts +373 -0
- package/src/api/base/ai_session.ts +85 -0
- package/src/api/base/ai_tool.ts +23 -0
- package/src/index.ts +16 -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/views/pages/my/my.vue +238 -0
- package/src/views/pagesMember/ai/components/Composer.vue +219 -0
- package/src/views/pagesMember/ai/components/SessionDrawer.vue +308 -0
- package/src/views/pagesMember/ai/components/WelcomePanel.vue +446 -0
- package/src/views/pagesMember/ai/index.vue +1453 -0
- package/src/views/pagesMember/ai/stream.ts +190 -0
- package/src/views/pagesMember/profile/profile.vue +393 -0
- package/src/views/pagesMember/settings/settings.vue +129 -0
|
@@ -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>
|