Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions ui/src/api/chat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,8 @@ Chat 业务接口统一调用后端 v3 路由(`/chat/api/v3/...`);文件
`postAnonymousAuth(accessToken?)` 与 `postLogin(loginRequest, accessToken?)` 传入 accessToken 时为应用认证,
否则为门户认证。`getCaptcha(username, accessToken)` 获取应用登录验证码,后端以 `accessToken`
查询参数接收应用令牌,暂不支持门户。`chat-user.ts` 的 `getChatUserProfile()` 获取当前对话用户。

`chat-user.ts` 的 `postResetPassword({ encryptedData })` 提交 RSA 加密的 `{ password, re_password }`,
公钥取当前认证配置的 `rsa_key`;成功后当前 token 失效,调用方清理认证状态并重新导航。
`getApiKey()` 与 `postApiKey()` 分别读取、重新生成 `/v3/api_key`,返回 `ChatUserApiKey`。
首次读取由后端创建密钥;每个用户仅保留一个,重新生成后旧密钥立即失效。
17 changes: 13 additions & 4 deletions ui/src/api/chat/chat-user.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
/** 提供当前对话用户档案接口。 */
/** 提供当前对话用户档案、密码与 API Key 接口。 */

import { get } from './core/request'
import type { ChatUserProfile } from '@/api/types'
import { get, post } from './core/request'
import type { ChatUserProfile, ChatUserApiKey } from '@/api/types'

/** 获取当前登录的对话用户档案。 */
const getChatUserProfile = () => {
return get<ChatUserProfile>('/v3/chat_user/profile')
}

export default { getChatUserProfile }
/** 修改当前用户密码,提交 RSA 加密后的表单。 */
const postResetPassword = (data: { encryptedData: string }) => post<{ encryptedData: string }, boolean>('/v3/chat_user/current/reset_password', data)

/** 获取当前用户 API Key;首次读取由服务端创建。 */
const getApiKey = () => get<ChatUserApiKey>('/v3/api_key')

/** 重新生成当前用户 API Key,旧 Key 立即失效。 */
const postApiKey = () => post<undefined, ChatUserApiKey>('/v3/api_key')

export default { getChatUserProfile, postResetPassword, getApiKey, postApiKey }
9 changes: 9 additions & 0 deletions ui/src/api/types/chat-user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,12 @@ export interface ChatUserAuthorizationPayload {
chat_user_id: string
is_auth: boolean
}

/** 当前对话用户的唯一 API Key。 */
export interface ChatUserApiKey {
id: string
secret_key: string
is_active: boolean
create_time: string
user_id: string
}
1 change: 1 addition & 0 deletions ui/src/conversation-panel/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type ConversationAppInfo = ChatApplicationProfile
export interface ConversationAccount {
nickName?: string
username?: string
token_quota?: any
}

export interface Conversation {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import ChatUserApi from '@/api/chat/chat-user'
import { copyText } from '@/utils/clipboard'

defineOptions({ name: 'ConversationApiKeyDialog' })

const router = useRouter()
const dialogVisible = ref(false)
const apiKey = ref('')
const loading = ref(false)
const apiUrl = computed(() => new URL(router.resolve({ name: 'portal-home' }).href, window.location.origin).href)
const maskedApiKey = computed(() => (apiKey.value ? `******${apiKey.value.slice(-4)}` : ''))

// 每次打开重新读取;重置直接替换当前用户的唯一密钥。
function loadApiKey(regenerate = false) {
if (loading.value) return
loading.value = true
return (regenerate ? ChatUserApi.postApiKey() : ChatUserApi.getApiKey())
.then((record) => {
if (dialogVisible.value) apiKey.value = record.secret_key
})
.catch(() => {})
.finally(() => {
loading.value = false
})
}

function open() {
dialogVisible.value = true
return loadApiKey()
}

function resetData() {
apiKey.value = ''
}

defineExpose({ open })
</script>

<template>
<MkDialog v-model="dialogVisible" title="API Key" @closed="resetData">
<template #subtitle>每个用户仅一个 API Key,绑定当前对话用户、与其同权。重置后旧 Key 立即失效</template>
<div class="pb-5">
<div class="mb-4">
<div class="mb-2">API URL</div>
<el-input :model-value="apiUrl" readonly>
<template #suffix>
<!-- 复制 API URL -->
<el-button text class="-mr-1" @click="copyText(apiUrl)">
<MkIcon name="icon_copy_outlined" />
</el-button>
</template>
</el-input>
</div>
<div class="mb-2">API Key</div>
<el-input :model-value="maskedApiKey" readonly>
<template #suffix>
<!-- 复制完整 API Key -->
<el-button text :disabled="loading || !apiKey" @click="copyText(apiKey)">
<MkIcon name="icon_copy_outlined" />
</el-button>
<MkTooltip content="重新生成">
<!-- 重新生成 API Key -->
<el-button text class="-mr-1" :loading="loading" @click="loadApiKey(true)">
<MkIcon name="icon_refresh_outlined" />
</el-button>
</MkTooltip>
</template>
</el-input>
</div>
</MkDialog>
</template>
123 changes: 123 additions & 0 deletions ui/src/conversation-panel/left-sidebar/account-menu/UserPwdDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import JSEncrypt from 'jsencrypt'
import type { FormInstance, FormRules } from 'element-plus'
import { useRouter } from 'vue-router'
import ChatUserApi from '@/api/chat/chat-user'
import type { UpdatePasswordForm } from '@/api/types'
import { useStore } from '@/stores/chat'
import { MsgError, MsgSuccess } from '@/utils/message'

defineOptions({ name: 'UserPwdDialog' })

const router = useRouter()
const { auth } = useStore()

const dialogVisible = ref(false)
const passwordSubmitting = ref(false)

const userPasswordFormRef = ref<FormInstance>()
const userPasswordForm = reactive<UpdatePasswordForm>({ password: '', re_password: '' })

function validateConfirmPassword(_rule: unknown, value: string, callback: (error?: Error) => void) {
if (!value) {
callback(new Error('请输入确认密码'))
} else if (value !== userPasswordForm.password) {
callback(new Error('输入的密码不一致'))
} else {
callback()
}
}

const userPasswordRules = reactive<FormRules<UpdatePasswordForm>>({
password: [
{ required: true, whitespace: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, max: 20, message: '长度应为 6-20 个字符', trigger: 'blur' },
],
re_password: [
{ required: true, whitespace: true, message: '请输入确认密码', trigger: 'blur' },
{ validator: validateConfirmPassword, trigger: 'blur' },
],
})

function open() {
dialogVisible.value = true
}

async function submitPassword() {
if (passwordSubmitting.value) return
userPasswordFormRef.value?.validate((valid) => {
if (!valid) return
passwordSubmitting.value = true
const encryptor = new JSEncrypt()
encryptor.setPublicKey(auth.authProfile?.rsa_key ?? '')
const encryptedData = encryptor.encrypt(JSON.stringify(userPasswordForm))
if (!encryptedData) {
MsgError('密码加密失败,请刷新后重试')
passwordSubmitting.value = false
return
}

return ChatUserApi.postResetPassword({ encryptedData })
.then(() => {
MsgSuccess('密码修改成功')

dialogVisible.value = false
auth.clearToken()
return router.replace({ path: router.currentRoute.value.fullPath, force: true }).then(() => undefined)
})
.catch(() => {})
.finally(() => {
passwordSubmitting.value = false
})
})
}

function resetData() {
Object.assign(userPasswordForm, { password: '', re_password: '' })
passwordSubmitting.value = false
userPasswordFormRef.value?.clearValidate()
}
defineExpose({ open })
</script>

<template>
<MkDialog v-model="dialogVisible" title="修改密码" @closed="resetData">
<el-form
ref="userPasswordFormRef"
:model="userPasswordForm"
:rules="userPasswordRules"
label-position="top"
require-asterisk-position="right"
@submit.prevent
>
<el-form-item label="新密码" prop="password">
<el-input
v-model="userPasswordForm.password"
autocomplete="new-password"
maxlength="20"
placeholder="请输入新密码"
show-password
type="password"
/>
</el-form-item>
<el-form-item label="确认密码" prop="re_password">
<el-input
v-model="userPasswordForm.re_password"
autocomplete="new-password"
maxlength="20"
placeholder="请再次输入新密码"
show-password
type="password"
/>
</el-form-item>
</el-form>

<template #footer>
<!-- 取消修改密码 -->
<el-button plain @click="dialogVisible = false">取消</el-button>
<!-- 保存新密码 -->
<el-button :loading="passwordSubmitting" type="primary" @click="submitPassword">保存</el-button>
</template>
</MkDialog>
</template>
107 changes: 107 additions & 0 deletions ui/src/conversation-panel/left-sidebar/account-menu/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<script setup lang="ts">
import { computed, ref, useTemplateRef } from 'vue'
import { useRouter } from 'vue-router'
import { useStore } from '@/stores/chat'
import { formatTokenNumber } from '@/utils/number'
import { QUOTA_TYPE } from '@/api/enums'
import { useConversationListStore } from '../conversation-list/index'
import UserPwdDialog from './UserPwdDialog.vue'
import ApiKeyDialog from './ApiKeyDialog.vue'

defineOptions({ name: 'ConversationAccountMenu' })

const { account } = useConversationListStore()
const { auth } = useStore()
const router = useRouter()

const menuVisible = ref(false)
const displayName = computed(() => account.value?.nickName || account.value?.username || '用户')

// 配额尚未接入时保留空值展示,不将缺失数据表示为零用量。
const tokenPercentage = computed(() => {
return Math.min(100, Math.max(0, (account.value?.token_quota?.used_tokens / account.value?.token_quota?.total_tokens) * 100))
})

const userPwdDialogRef = useTemplateRef<InstanceType<typeof UserPwdDialog>>('userPwdDialogRef')
const apiKeyDialogRef = useTemplateRef<InstanceType<typeof ApiKeyDialog>>('apiKeyDialogRef')

// 退出对话账号后重新导航,由守卫处理后续认证。
const handleLogout = () => {
return auth
.logout()
.then(() => router.replace({ path: router.currentRoute.value.fullPath, force: true }))
.catch(() => {})
}
</script>

<template>
<MkDropdown v-if="account" class="w-full" trigger="click" placement="top-start" @visible-change="menuVisible = $event">
<!-- 展开账户菜单 -->
<button
type="button"
class="flex-align-center w-full cursor-pointer gap-2 rounded-md py-1 px-2 hover:bg-N900/10"
:class="{ 'bg-N900/10': menuVisible }"
>
<el-avatar :size="32" class="bg-primary-gradient!">
<img src="@/assets/mk_icon_user_gradient.svg" alt="" style="width: 54%" />
</el-avatar>
<span class="truncate" :title="displayName">{{ displayName }}</span>
</button>
<template #dropdown>
<div class="w-60">
<div class="p-3">
<div class="mb-2 flex-align-center gap-2">
<el-avatar :size="40" class="shrink-0 bg-primary-gradient!">
<img src="@/assets/mk_icon_user_gradient.svg" alt="" style="width: 54%" />
</el-avatar>
<div class="min-w-0">
<h4 class="truncate" :title="displayName">{{ displayName }}</h4>
<div class="truncate text-N600" :title="account?.username">{{ account?.username || '-' }}</div>
</div>
</div>
<template v-if="account?.token_quota">
<div class="mb-1 flex-align-center">
<span class="mr-3">Tokens</span>
<span class="text-primary">{{ formatTokenNumber(account?.token_quota?.used_tokens) }}</span>
<span class="text-N500">
/{{ account?.token_quota?.quota_type === QUOTA_TYPE.UNLIMITED ? '不限' : formatTokenNumber(account?.token_quota?.total_tokens) }}
</span>
</div>

<el-progress
v-if="account?.token_quota?.quota_type !== QUOTA_TYPE.UNLIMITED"
:percentage="tokenPercentage"
:status="tokenPercentage > 80 ? 'warning' : undefined"
:show-text="false"
:stroke-width="6"
/>
</template>
</div>
<el-divider />
<MkDropdownMenu>
<!-- 修改密码 -->
<MkDropdownItem @click="userPwdDialogRef?.open()">
<template #icon><MkIcon name="icon-key_outlined" /></template>
<span>修改密码</span>
</MkDropdownItem>
<!-- 管理 API Key -->
<MkDropdownItem @click="apiKeyDialogRef?.open()">
<template #icon><MkIcon name="icon_passkeys_outlined" /></template>
<span>API Key</span>
</MkDropdownItem>
<!-- 退出登录 -->
<MkDropdownItem divided @click="handleLogout">
<template #icon><MkIcon name="icon_logout_outlined" /></template>
<span>退出登录</span>
</MkDropdownItem>
</MkDropdownMenu>
</div>
</template>
</MkDropdown>
<!-- 匿名登陆 -->
<el-button v-else type="primary" class="w-full">立即登录</el-button>
<UserPwdDialog ref="userPwdDialogRef" />
<ApiKeyDialog ref="apiKeyDialogRef" />
</template>

<style scoped lang="scss"></style>
Loading
Loading