feat(auth): 后台登录、角色权限与会话访问策略
管理员与客服分角色登录,权限由后端判定;前端补齐登录、人员与组织管理页面。
This commit is contained in:
1 parent
5e0615f36d
commit
e24d3e2a65
123 files changed
+25293
No files matched your search
@@ -0,0 +1,192 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* The modal shell: scrim, panel, titled header with a close button, and the
|
||||
* behaviour a dialog owes a keyboard.
|
||||
*
|
||||
* Extracted because three WhatsApp modals had grown byte-identical copies of
|
||||
* all of it — same scrim, same panel, same header, same buttons, same
|
||||
* role/aria-modal/tabindex/click.self/keydown.esc scaffolding, differing only
|
||||
* in a CSS prefix. Each copy was also missing a focus trap in the same way, and
|
||||
* fixing that three times is how the fourth modal ends up without one.
|
||||
*
|
||||
* Slots: default (body) and `actions` (footer buttons). `width` sizes the panel.
|
||||
* `dismissible: false` closes it only from its own buttons — no ×, scrim
|
||||
* click or Escape — for a dialog whose content is lost once closed.
|
||||
*/
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
title: string
|
||||
/** Panel width; the shell caps it to the viewport. */
|
||||
width?: string
|
||||
dismissible?: boolean
|
||||
}>(), { width: '520px', dismissible: true })
|
||||
|
||||
const dismiss = () => {
|
||||
if (props.dismissible) emit('close')
|
||||
}
|
||||
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
|
||||
const dialog = ref<HTMLElement | null>(null)
|
||||
/** Whatever had focus before we opened — where it must go back to. */
|
||||
let previouslyFocused: HTMLElement | null = null
|
||||
|
||||
const FOCUSABLE = [
|
||||
'a[href]', 'button:not([disabled])', 'input:not([disabled])',
|
||||
'select:not([disabled])', 'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"])',
|
||||
].join(',')
|
||||
|
||||
/**
|
||||
* Keep Tab inside the dialog.
|
||||
*
|
||||
* aria-modal="true" tells assistive tech that everything behind this is
|
||||
* hidden. Without a trap that's a lie the first time the user tabs past the
|
||||
* last button: they land in content their screen reader has been told does not
|
||||
* exist, with no way back but Escape.
|
||||
*/
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
dismiss()
|
||||
return
|
||||
}
|
||||
if (event.key !== 'Tab' || !dialog.value) return
|
||||
|
||||
const focusable = Array.from(
|
||||
dialog.value.querySelectorAll<HTMLElement>(FOCUSABLE),
|
||||
).filter((element) => element.offsetParent !== null)
|
||||
if (!focusable.length) return
|
||||
|
||||
const first = focusable[0]
|
||||
const last = focusable[focusable.length - 1]
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
|
||||
// Wrap at both ends. The container itself is focusable (tabindex="-1") and
|
||||
// is where focus starts, so treat it as "before the first".
|
||||
if (event.shiftKey && (active === first || active === dialog.value)) {
|
||||
event.preventDefault()
|
||||
last.focus()
|
||||
} else if (!event.shiftKey && active === last) {
|
||||
event.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
previouslyFocused = document.activeElement as HTMLElement | null
|
||||
dialog.value?.focus()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// Returning focus to the trigger is the other half of the contract: without
|
||||
// it, closing drops the user back at the top of the document.
|
||||
previouslyFocused?.focus?.()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="dialog"
|
||||
class="bm-scrim"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="title"
|
||||
tabindex="-1"
|
||||
@click.self="dismiss"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<div class="bm-panel" :style="{ '--bm-width': props.width }">
|
||||
<div class="bm-header">
|
||||
<h3 class="bm-title">{{ title }}</h3>
|
||||
<button v-if="dismissible" class="bm-close" aria-label="关闭" @click="emit('close')">×</button>
|
||||
</div>
|
||||
|
||||
<div class="bm-body">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.actions" class="bm-actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bm-scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--scrim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.bm-scrim:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.bm-panel {
|
||||
background: var(--background-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
width: min(var(--bm-width, 520px), 100%);
|
||||
max-height: calc(100vh - 32px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.bm-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.bm-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
.bm-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.bm-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.bm-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
channel?: string | null
|
||||
}>()
|
||||
|
||||
const CHANNEL_LABELS: Record<string, string> = {
|
||||
shopify: 'Shopify',
|
||||
telegram: 'Telegram',
|
||||
whatsapp: 'WhatsApp',
|
||||
messenger: 'Messenger',
|
||||
instagram: 'Instagram',
|
||||
slack: 'Slack',
|
||||
email: 'Email',
|
||||
sms: 'SMS',
|
||||
line: 'LINE',
|
||||
api: 'API',
|
||||
}
|
||||
|
||||
// The widget ('web') is the default context — no badge needed for it.
|
||||
const label = computed(() => (props.channel ? CHANNEL_LABELS[props.channel] : null))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span v-if="label" class="channel-badge" :class="`channel-${channel}`">{{ label }}</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.channel-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 8px;
|
||||
border-radius: var(--radius-chip, 999px);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.6;
|
||||
background: var(--accent-bg-12, rgba(59, 130, 246, 0.12));
|
||||
color: var(--accent-ink, #2563eb);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,795 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { resolveWidgetUploadUrl } from '../../webclient/widget-env'
|
||||
|
||||
// Security: Allowed file types (matching backend)
|
||||
const ALLOWED_FILE_TYPES = new Set([
|
||||
'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp',
|
||||
'application/pdf', 'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'text/plain', 'text/csv', 'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
])
|
||||
|
||||
const EXTENSION_TO_MIME: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
|
||||
'.gif': 'image/gif', '.webp': 'image/webp', '.pdf': 'application/pdf',
|
||||
'.doc': 'application/msword',
|
||||
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'.txt': 'text/plain', '.csv': 'text/csv', '.xls': 'application/vnd.ms-excel',
|
||||
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
}
|
||||
|
||||
// Validate extension matches MIME type
|
||||
function validateExtension(filename: string, mimeType: string): { valid: boolean; error?: string } {
|
||||
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'))
|
||||
if (!ext || ext === '.') return { valid: false, error: '文件必须包含扩展名' }
|
||||
if (!(ext in EXTENSION_TO_MIME)) {
|
||||
return { valid: false, error: `不支持该文件类型。允许的类型:${Object.keys(EXTENSION_TO_MIME).map(e => e.replace('.', '').toUpperCase()).join(', ')}` }
|
||||
}
|
||||
const expectedMime = EXTENSION_TO_MIME[ext]
|
||||
if ((mimeType === 'image/jpeg' || mimeType === 'image/jpg') && (expectedMime === 'image/jpeg' || expectedMime === 'image/jpg')) return { valid: true }
|
||||
if (expectedMime !== mimeType) return { valid: false, error: `文件扩展名 ${ext} 与内容类型不匹配` }
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
// Validate magic bytes
|
||||
async function validateMagicBytes(file: File): Promise<{ valid: boolean; error?: string }> {
|
||||
const mimeType = file.type
|
||||
if (mimeType === 'text/plain' || mimeType === 'text/csv') return { valid: true }
|
||||
if (mimeType.includes('openxmlformats') || mimeType === 'application/msword') {
|
||||
const buffer = await file.slice(0, 4).arrayBuffer()
|
||||
const bytes = new Uint8Array(buffer)
|
||||
if ((bytes[0] === 0x50 && bytes[1] === 0x4b) || (bytes[0] === 0xd0 && bytes[1] === 0xcf)) return { valid: true }
|
||||
return { valid: false, error: '文件内容与 Office 文档格式不匹配' }
|
||||
}
|
||||
const MAGIC_BYTES: Record<string, number[][]> = {
|
||||
'image/jpeg': [[0xff, 0xd8, 0xff, 0xe0], [0xff, 0xd8, 0xff, 0xe1], [0xff, 0xd8, 0xff, 0xdb]],
|
||||
'image/png': [[0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]],
|
||||
'image/gif': [[0x47, 0x49, 0x46, 0x38, 0x37, 0x61], [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]],
|
||||
'application/pdf': [[0x25, 0x50, 0x44, 0x46, 0x2d]]
|
||||
}
|
||||
const signatures = MAGIC_BYTES[mimeType]
|
||||
if (!signatures) return { valid: true }
|
||||
const buffer = await file.slice(0, 16).arrayBuffer()
|
||||
const fileBytes = new Uint8Array(buffer)
|
||||
if (mimeType === 'image/webp') {
|
||||
if (fileBytes[0] === 0x52 && fileBytes[1] === 0x49 && fileBytes[2] === 0x46 && fileBytes[3] === 0x46 &&
|
||||
fileBytes[8] === 0x57 && fileBytes[9] === 0x45 && fileBytes[10] === 0x42 && fileBytes[11] === 0x50) return { valid: true }
|
||||
return { valid: false, error: '文件内容与 WebP 格式不匹配' }
|
||||
}
|
||||
for (const sig of signatures) {
|
||||
let match = true
|
||||
for (let i = 0; i < sig.length; i++) { if (fileBytes[i] !== sig[i]) { match = false; break } }
|
||||
if (match) return { valid: true }
|
||||
}
|
||||
return { valid: false, error: `文件内容与 ${mimeType} 格式不匹配` }
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
token?: string
|
||||
authorization?: string
|
||||
maxFiles?: number
|
||||
acceptTypes?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'filesUploaded', files: Array<{content: string, filename: string, content_type: string, size: number}>): void
|
||||
(e: 'error', error: string): void
|
||||
}>()
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const uploadedFiles = ref<Array<{file: File, content: string, url: string, file_url: string, filename: string, type: string, size: number}>>([])
|
||||
const uploading = ref(false)
|
||||
const dragOver = ref(false)
|
||||
|
||||
const maxFiles = computed(() => props.maxFiles || 3)
|
||||
const acceptTypes = computed(() => props.acceptTypes || 'image/*,.pdf,.doc,.docx,.txt,.csv,.xlsx,.xls')
|
||||
|
||||
const canUploadMore = computed(() => uploadedFiles.value.length < maxFiles.value)
|
||||
|
||||
const handleFileSelect = async (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (target.files && target.files.length > 0) {
|
||||
await uploadFiles(Array.from(target.files))
|
||||
// Reset the input value to allow selecting the same file again
|
||||
target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = async (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
dragOver.value = false
|
||||
|
||||
if (event.dataTransfer?.files && event.dataTransfer.files.length > 0) {
|
||||
await uploadFiles(Array.from(event.dataTransfer.files))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
dragOver.value = true
|
||||
}
|
||||
|
||||
const handleDragLeave = () => {
|
||||
dragOver.value = false
|
||||
}
|
||||
|
||||
const handlePaste = async (event: ClipboardEvent) => {
|
||||
const items = event.clipboardData?.items
|
||||
if (!items) return
|
||||
|
||||
const files: File[] = []
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile()
|
||||
if (file) {
|
||||
files.push(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
event.preventDefault()
|
||||
await uploadFiles(files)
|
||||
}
|
||||
}
|
||||
|
||||
// Compress image if it's too large
|
||||
const compressImage = async (file: File, maxSizeKB: number = 500): Promise<{blob: Blob, base64: string}> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
let width = img.width
|
||||
let height = img.height
|
||||
|
||||
// Calculate new dimensions (max 1920px width/height while maintaining aspect ratio)
|
||||
const maxDimension = 1920
|
||||
if (width > maxDimension || height > maxDimension) {
|
||||
if (width > height) {
|
||||
height = (height / width) * maxDimension
|
||||
width = maxDimension
|
||||
} else {
|
||||
width = (width / height) * maxDimension
|
||||
height = maxDimension
|
||||
}
|
||||
}
|
||||
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) {
|
||||
reject(new Error('Failed to get canvas context'))
|
||||
return
|
||||
}
|
||||
|
||||
ctx.drawImage(img, 0, 0, width, height)
|
||||
|
||||
// Start with quality 0.9 and reduce if needed
|
||||
let quality = 0.9
|
||||
const tryCompress = () => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
reject(new Error('Failed to compress image'))
|
||||
return
|
||||
}
|
||||
|
||||
const sizeKB = blob.size / 1024
|
||||
|
||||
// If still too large and quality can be reduced, try again
|
||||
if (sizeKB > maxSizeKB && quality > 0.3) {
|
||||
quality -= 0.1
|
||||
tryCompress()
|
||||
} else {
|
||||
// Convert blob to base64
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const base64 = (reader.result as string).split(',')[1]
|
||||
resolve({ blob, base64 })
|
||||
}
|
||||
reader.readAsDataURL(blob)
|
||||
}
|
||||
}, file.type === 'image/png' ? 'image/png' : 'image/jpeg', quality)
|
||||
}
|
||||
|
||||
tryCompress()
|
||||
}
|
||||
img.onerror = () => reject(new Error('Failed to load image'))
|
||||
img.src = e.target?.result as string
|
||||
}
|
||||
reader.onerror = () => reject(new Error('Failed to read file'))
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
const uploadFiles = async (files: File[]) => {
|
||||
if (!canUploadMore.value) {
|
||||
emit('error', `最多允许 ${maxFiles.value} 个文件`)
|
||||
return
|
||||
}
|
||||
|
||||
const remainingSlots = maxFiles.value - uploadedFiles.value.length
|
||||
const filesToUpload = files.slice(0, remainingSlots)
|
||||
|
||||
if (files.length > remainingSlots) {
|
||||
emit('error', `最多还能上传 ${remainingSlots} 个文件`)
|
||||
}
|
||||
|
||||
const TARGET_SIZE_KB = 500 // Target 500KB after compression
|
||||
|
||||
// Read files and store them temporarily (will be uploaded when message is sent)
|
||||
for (const file of filesToUpload) {
|
||||
try {
|
||||
// Check if file with same name already exists
|
||||
const isDuplicate = uploadedFiles.value.some(f => f.filename === file.name)
|
||||
if (isDuplicate) {
|
||||
console.warn(`File ${file.name} is already selected`)
|
||||
emit('error', `文件“${file.name}”已被选择`)
|
||||
continue
|
||||
}
|
||||
|
||||
// SECURITY: Validate file type is allowed
|
||||
if (!ALLOWED_FILE_TYPES.has(file.type)) {
|
||||
const allowed = Object.keys(EXTENSION_TO_MIME).map(e => e.toUpperCase().replace('.', '')).join(', ')
|
||||
emit('error', `不支持文件类型“${file.type}”。允许的类型:${allowed}`)
|
||||
continue
|
||||
}
|
||||
|
||||
// SECURITY: Validate extension matches MIME type
|
||||
const extValidation = validateExtension(file.name, file.type)
|
||||
if (!extValidation.valid) {
|
||||
emit('error', `安全检查失败:${extValidation.error}`)
|
||||
continue
|
||||
}
|
||||
|
||||
// SECURITY: Validate magic bytes
|
||||
const magicValidation = await validateMagicBytes(file)
|
||||
if (!magicValidation.valid) {
|
||||
emit('error', `安全检查失败:${magicValidation.error}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const isImage = file.type.startsWith('image/')
|
||||
|
||||
if (isImage) {
|
||||
// Compress image before upload
|
||||
try {
|
||||
const { blob, base64 } = await compressImage(file, TARGET_SIZE_KB)
|
||||
const compressedSize = blob.size
|
||||
|
||||
console.log(`Compressed ${file.name}: ${(file.size / 1024).toFixed(2)}KB → ${(compressedSize / 1024).toFixed(2)}KB`)
|
||||
|
||||
uploadedFiles.value.push({
|
||||
file: file,
|
||||
content: base64,
|
||||
filename: file.name,
|
||||
type: file.type,
|
||||
size: compressedSize,
|
||||
url: URL.createObjectURL(blob),
|
||||
file_url: URL.createObjectURL(blob)
|
||||
})
|
||||
|
||||
// Emit the files after compression
|
||||
emit('filesUploaded', uploadedFiles.value.map(f => ({
|
||||
content: f.content,
|
||||
filename: f.filename,
|
||||
content_type: f.type,
|
||||
size: f.size
|
||||
})))
|
||||
} catch (error) {
|
||||
console.error('Image compression failed, uploading original:', error)
|
||||
// Fallback to original file if compression fails
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
const base64Content = e.target?.result as string
|
||||
const base64Data = base64Content.split(',')[1]
|
||||
|
||||
uploadedFiles.value.push({
|
||||
file: file,
|
||||
content: base64Data,
|
||||
filename: file.name,
|
||||
type: file.type || 'application/octet-stream',
|
||||
size: file.size,
|
||||
url: URL.createObjectURL(file),
|
||||
file_url: URL.createObjectURL(file)
|
||||
})
|
||||
|
||||
emit('filesUploaded', uploadedFiles.value.map(f => ({
|
||||
content: f.content,
|
||||
filename: f.filename,
|
||||
content_type: f.type,
|
||||
size: f.size
|
||||
})))
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
} else {
|
||||
// For non-images, read as-is
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
const base64Content = e.target?.result as string
|
||||
const base64Data = base64Content.split(',')[1]
|
||||
|
||||
uploadedFiles.value.push({
|
||||
file: file,
|
||||
content: base64Data,
|
||||
filename: file.name,
|
||||
type: file.type || 'application/octet-stream',
|
||||
size: file.size,
|
||||
url: '',
|
||||
file_url: ''
|
||||
})
|
||||
|
||||
emit('filesUploaded', uploadedFiles.value.map(f => ({
|
||||
content: f.content,
|
||||
filename: f.filename,
|
||||
content_type: f.type,
|
||||
size: f.size
|
||||
})))
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('File read error:', error)
|
||||
emit('error', `读取文件 ${file.name} 失败`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
const file = uploadedFiles.value[index]
|
||||
if (!file) return
|
||||
|
||||
// Revoke object URL if it exists to free memory
|
||||
if (file.url && file.url.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(file.url)
|
||||
}
|
||||
if (file.file_url && file.file_url.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(file.file_url)
|
||||
}
|
||||
|
||||
// Remove from local array
|
||||
uploadedFiles.value.splice(index, 1)
|
||||
emit('filesUploaded', uploadedFiles.value.map(f => ({
|
||||
content: f.content,
|
||||
filename: f.filename,
|
||||
content_type: f.type,
|
||||
size: f.size
|
||||
})))
|
||||
}
|
||||
|
||||
const openFilePicker = () => {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
}
|
||||
|
||||
const isImage = (type: string | undefined | null): boolean => {
|
||||
return type ? type.startsWith('image/') : false
|
||||
}
|
||||
|
||||
// Generate preview URL for file display
|
||||
const getPreviewUrl = (file: {url: string, file_url: string}): string => {
|
||||
// If file_url is a blob URL (temporary preview), return as-is
|
||||
if (file.file_url.startsWith('blob:')) {
|
||||
return file.file_url
|
||||
}
|
||||
|
||||
// Absolute S3 URLs pass through; local paths (including
|
||||
// /api/v1/files/download/...) already carry the prefix, so they resolve
|
||||
// against the API origin rather than being concatenated onto the API base.
|
||||
return resolveWidgetUploadUrl(file.file_url)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
uploadedFiles,
|
||||
clearFiles: () => { uploadedFiles.value = [] },
|
||||
handlePaste
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="file-upload-container">
|
||||
<!-- Hidden file input -->
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
:accept="acceptTypes"
|
||||
multiple
|
||||
style="display: none"
|
||||
@change="handleFileSelect"
|
||||
/>
|
||||
|
||||
<!-- Upload button -->
|
||||
<button
|
||||
v-if="canUploadMore"
|
||||
type="button"
|
||||
class="upload-button"
|
||||
:disabled="uploading"
|
||||
@click="openFilePicker"
|
||||
:title="'附加文件(或粘贴截图)'"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Drop zone overlay -->
|
||||
<div
|
||||
v-if="dragOver"
|
||||
class="drop-zone-overlay"
|
||||
@drop="handleDrop"
|
||||
@dragover="handleDragOver"
|
||||
@dragleave="handleDragLeave"
|
||||
>
|
||||
<div class="drop-zone-content">
|
||||
<svg
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="17 8 12 3 7 8"></polyline>
|
||||
<line x1="12" y1="3" x2="12" y2="15"></line>
|
||||
</svg>
|
||||
<p>拖拽文件到此处</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File previews -->
|
||||
<div v-if="uploadedFiles.length > 0" class="file-previews">
|
||||
<div
|
||||
v-for="(file, index) in uploadedFiles"
|
||||
:key="index"
|
||||
class="file-preview"
|
||||
>
|
||||
<div class="file-preview-content">
|
||||
<img
|
||||
v-if="isImage(file.type)"
|
||||
:src="getPreviewUrl(file)"
|
||||
:alt="file.filename"
|
||||
class="file-preview-image"
|
||||
/>
|
||||
<div v-else class="file-preview-icon">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
|
||||
<polyline points="13 2 13 9 20 9"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-preview-info">
|
||||
<div class="file-preview-name">{{ file.filename }}</div>
|
||||
<div class="file-preview-size">{{ formatFileSize(file.size) }}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="file-preview-remove"
|
||||
@click="removeFile(index)"
|
||||
:title="'删除文件'"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload progress -->
|
||||
<div v-if="uploading" class="upload-progress">
|
||||
<div class="upload-spinner"></div>
|
||||
<span>上传中……</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-upload-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.upload-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
border-radius: 50%;
|
||||
transition: all 0.2s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.upload-button:hover:not(:disabled) {
|
||||
background: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.upload-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.drop-zone-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.drop-zone-content {
|
||||
background: var(--surface);
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.drop-zone-content svg {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.drop-zone-content p {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.file-previews {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.03) 0%, rgba(59, 130, 246, 0.01) 100%);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 10px;
|
||||
border: 1px dashed rgba(59, 130, 246, 0.2);
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.file-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #fafbfc 100%);
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 14px;
|
||||
font-size: 13px;
|
||||
position: relative;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow:
|
||||
0 2px 4px rgba(0, 0, 0, 0.04),
|
||||
0 1px 2px rgba(0, 0, 0, 0.02);
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-preview::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
background: linear-gradient(180deg,
|
||||
#3b82f6 0%,
|
||||
#60a5fa 50%,
|
||||
#93c5fd 100%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.file-preview:hover {
|
||||
box-shadow:
|
||||
0 8px 16px rgba(59, 130, 246, 0.12),
|
||||
0 4px 8px rgba(59, 130, 246, 0.08);
|
||||
transform: translateY(-2px);
|
||||
border-color: #93c5fd;
|
||||
}
|
||||
|
||||
.file-preview:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.file-preview-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
box-shadow:
|
||||
0 2px 8px rgba(59, 130, 246, 0.1),
|
||||
inset 0 1px 2px rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.file-preview-content::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(135deg,
|
||||
rgba(255, 255, 255, 0.4) 0%,
|
||||
transparent 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.file-preview-image {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
border-radius: 10px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.file-preview:hover .file-preview-image {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.file-preview-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #3b82f6;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.file-preview:hover .file-preview-icon {
|
||||
transform: scale(1.1) rotate(-5deg);
|
||||
}
|
||||
|
||||
.file-preview-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.file-preview-name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.file-preview-size {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.file-preview-remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
|
||||
border: 1.5px solid #fca5a5;
|
||||
border-radius: 8px;
|
||||
color: #dc2626;
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
line-height: 1;
|
||||
box-shadow: 0 2px 4px rgba(220, 38, 38, 0.1);
|
||||
}
|
||||
|
||||
.file-preview-remove:hover {
|
||||
background: linear-gradient(135deg, #fca5a5 0%, #f87171 100%);
|
||||
border-color: #ef4444;
|
||||
color: white;
|
||||
transform: scale(1.1) rotate(90deg);
|
||||
box-shadow: 0 4px 8px rgba(220, 38, 38, 0.25);
|
||||
}
|
||||
|
||||
.file-preview-remove:active {
|
||||
transform: scale(1) rotate(90deg);
|
||||
box-shadow: 0 2px 4px rgba(220, 38, 38, 0.2);
|
||||
}
|
||||
|
||||
.upload-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.upload-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #f3f3f3;
|
||||
border-top: 2px solid #666;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,181 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* A textarea for prompt text an AI reads, rather than a label a human reads.
|
||||
* Monospace because the content is index globs and field names, and it ships
|
||||
* a worked example behind a disclosure — a placeholder vanishes on the first
|
||||
* keystroke, which is exactly when the operator still needs the shape.
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { MAX_GUIDANCE_CHARS } from '@/utils/mcp'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
// Nullable: the API returns null for a connector nobody has documented,
|
||||
// and callers bind that straight through.
|
||||
modelValue: string | null | undefined
|
||||
label: string
|
||||
hint: string
|
||||
placeholder?: string
|
||||
example?: string
|
||||
rows?: number
|
||||
maxlength?: number
|
||||
}>(),
|
||||
{ placeholder: '', example: '', rows: 6, maxlength: MAX_GUIDANCE_CHARS },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', value: string): void }>()
|
||||
|
||||
const showExample = ref(false)
|
||||
const text = computed(() => props.modelValue ?? '')
|
||||
const isNearLimit = computed(() => text.value.length > props.maxlength * 0.9)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="guidance">
|
||||
<span class="guidance-label">{{ label }}</span>
|
||||
<p class="guidance-hint">{{ hint }}</p>
|
||||
<textarea
|
||||
class="guidance-input"
|
||||
:value="text"
|
||||
:aria-label="label"
|
||||
:rows="rows"
|
||||
:maxlength="maxlength"
|
||||
:placeholder="placeholder"
|
||||
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
|
||||
></textarea>
|
||||
<div class="guidance-foot">
|
||||
<button v-if="example" type="button" class="example-toggle" @click="showExample = !showExample">
|
||||
<font-awesome-icon :icon="['fas', showExample ? 'chevron-up' : 'chevron-down']" />
|
||||
{{ showExample ? '隐藏示例' : '查看示例' }}
|
||||
</button>
|
||||
<span v-else></span>
|
||||
<span class="guidance-count mono" :class="{ near: isNearLimit }">
|
||||
{{ text.length }} / {{ maxlength }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="example && showExample" class="guidance-example">
|
||||
<pre class="example-text mono">{{ example }}</pre>
|
||||
<!-- Offered only while the field is empty, so it can never overwrite
|
||||
something the operator actually wrote. -->
|
||||
<button
|
||||
v-if="!text.length"
|
||||
type="button"
|
||||
class="example-use"
|
||||
@click="emit('update:modelValue', example)"
|
||||
>
|
||||
用作起点
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.guidance {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.guidance-label {
|
||||
font-size: 11px;
|
||||
color: var(--faint);
|
||||
}
|
||||
.guidance-hint {
|
||||
font-size: 11.5px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
.guidance-input {
|
||||
padding: 9px 11px;
|
||||
min-height: 130px;
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: 9px;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.55;
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
}
|
||||
.guidance-input:focus {
|
||||
border-color: var(--accent-ink);
|
||||
}
|
||||
.guidance-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.example-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
font-size: 11.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.example-toggle:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
.guidance-count {
|
||||
font-size: 10.5px;
|
||||
color: var(--muted2);
|
||||
}
|
||||
.guidance-count.near {
|
||||
color: var(--c-warn);
|
||||
}
|
||||
.guidance-example {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 11px 13px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o07);
|
||||
border-radius: 9px;
|
||||
}
|
||||
.example-text {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
color: var(--text3);
|
||||
white-space: pre-wrap;
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
.example-use {
|
||||
padding: 5px 11px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: 8px;
|
||||
color: var(--muted);
|
||||
font-size: 11.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.example-use:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,119 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useId } from 'vue'
|
||||
|
||||
const titleId = useId()
|
||||
const props = withDefaults(defineProps<{
|
||||
title?: string
|
||||
// When true, clicking the backdrop does NOT close the modal — for forms
|
||||
// where an accidental outside-click would discard the user's input.
|
||||
persistent?: boolean
|
||||
}>(), { persistent: false })
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const onOverlayClick = () => {
|
||||
if (!props.persistent) emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal-overlay" @click="onOverlayClick">
|
||||
<div class="modal-content" role="dialog" aria-modal="true" :aria-labelledby="titleId" @click.stop>
|
||||
<div class="modal-header">
|
||||
<h3 :id="titleId" class="modal-title">
|
||||
<slot name="title"></slot>
|
||||
</h3>
|
||||
<button class="close-button" aria-label="关闭" @click="$emit('close')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<slot name="content"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(5, 6, 9, 0.66);
|
||||
backdrop-filter: blur(3px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: 20px;
|
||||
padding: 28px;
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 8px;
|
||||
font-size: 18px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: var(--o10);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
margin-top: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,114 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { PasswordStrength } from '@/utils/validators'
|
||||
|
||||
const props = defineProps<{
|
||||
strength: PasswordStrength
|
||||
}>()
|
||||
|
||||
const TOTAL_CHECKS = 5
|
||||
|
||||
const strengthClass = computed(() => {
|
||||
if (props.strength.score >= 4) return 'strong'
|
||||
if (props.strength.score >= 3) return 'medium'
|
||||
return 'weak'
|
||||
})
|
||||
|
||||
const requirements = computed(() => [
|
||||
{ label: '至少 8 个字符', met: props.strength.hasMinLength },
|
||||
{ label: '至少一个大写字母', met: props.strength.hasUpperCase },
|
||||
{ label: '至少一个小写字母', met: props.strength.hasLowerCase },
|
||||
{ label: '至少一个数字', met: props.strength.hasNumber },
|
||||
{ label: '至少一个特殊字符', met: props.strength.hasSpecialChar },
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="password-strength">
|
||||
<div class="strength-meter">
|
||||
<div
|
||||
class="strength-bar"
|
||||
:class="strengthClass"
|
||||
:style="{ width: `${(strength.score / TOTAL_CHECKS) * 100}%` }"
|
||||
/>
|
||||
</div>
|
||||
<ul class="strength-requirements">
|
||||
<li v-for="req in requirements" :key="req.label" :class="{ met: req.met }">
|
||||
{{ req.label }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.password-strength {
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.strength-meter {
|
||||
height: 4px;
|
||||
background: var(--background-mute);
|
||||
border-radius: var(--radius-full);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.strength-bar {
|
||||
height: 100%;
|
||||
border-radius: var(--radius-full);
|
||||
transition: width var(--transition-normal);
|
||||
}
|
||||
|
||||
.strength-bar.weak {
|
||||
background: var(--error-color);
|
||||
}
|
||||
|
||||
.strength-bar.medium {
|
||||
background: var(--warning-color);
|
||||
}
|
||||
|
||||
.strength-bar.strong {
|
||||
background: var(--success-color);
|
||||
}
|
||||
|
||||
.strength-requirements {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.strength-requirements li {
|
||||
margin-bottom: var(--space-xs);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.strength-requirements li::before {
|
||||
content: '×';
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.strength-requirements li.met::before {
|
||||
content: '✓';
|
||||
color: var(--success-color);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { UserGroup } from '@/types/user'
|
||||
|
||||
const props = defineProps<{
|
||||
group?: UserGroup | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [groupData: Partial<UserGroup>]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const formData = ref({
|
||||
name: props.group?.name || '',
|
||||
description: props.group?.description || ''
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit('submit', formData.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="handleSubmit" class="group-form">
|
||||
<div class="form-group">
|
||||
<label for="name">名称</label>
|
||||
<input
|
||||
id="name"
|
||||
v-model="formData.name"
|
||||
type="text"
|
||||
required
|
||||
class="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">描述</label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="formData.description"
|
||||
class="form-input"
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="emit('cancel')">
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{{ props.group ? '更新' : '创建' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.group-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-md);
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,544 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, computed } from 'vue'
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
import GroupForm from './GroupForm.vue'
|
||||
import { useGroups } from '@/composables/useGroups'
|
||||
import type { User, UserGroup } from '@/types/user'
|
||||
|
||||
const props = defineProps<{
|
||||
searchQuery?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'changed'): void
|
||||
}>()
|
||||
|
||||
const {
|
||||
groups,
|
||||
users,
|
||||
loading,
|
||||
error,
|
||||
showCreateModal,
|
||||
showEditModal,
|
||||
showMembersModal,
|
||||
showDeleteModal,
|
||||
selectedGroup,
|
||||
selectedUsers,
|
||||
fetchGroups,
|
||||
handleCreateGroup,
|
||||
handleEditGroup,
|
||||
handleUpdateGroup,
|
||||
handleManageMembers,
|
||||
handleUserSelection,
|
||||
handleDeleteGroup,
|
||||
handleDeleteConfirm
|
||||
} = useGroups()
|
||||
|
||||
// Wrap create/delete to emit `changed` for the parent view's counters.
|
||||
const onCreateGroup = async (groupData: Partial<UserGroup>) => {
|
||||
await handleCreateGroup(groupData)
|
||||
emit('changed')
|
||||
}
|
||||
|
||||
const onDeleteConfirm = async () => {
|
||||
await handleDeleteConfirm()
|
||||
emit('changed')
|
||||
}
|
||||
|
||||
// Filter cards by group name (case-insensitive).
|
||||
const filteredGroups = computed(() => {
|
||||
const q = (props.searchQuery || '').trim().toLowerCase()
|
||||
if (!q) return groups.value
|
||||
return groups.value.filter(g => g.name.toLowerCase().includes(q))
|
||||
})
|
||||
|
||||
const onlineCount = (group: UserGroup) =>
|
||||
group.users?.filter(u => u.is_online).length || 0
|
||||
|
||||
const memberCount = (group: UserGroup) => group.users?.length || 0
|
||||
|
||||
// Up to ~4 avatars in the stack.
|
||||
const stackedMembers = (group: UserGroup) => (group.users || []).slice(0, 4)
|
||||
|
||||
const initials = (user: User) => {
|
||||
const name = (user.full_name || '').trim()
|
||||
if (!name) return '?'
|
||||
const parts = name.split(/\s+/)
|
||||
if (parts.length === 1) return parts[0].charAt(0).toUpperCase()
|
||||
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase()
|
||||
}
|
||||
|
||||
// Deterministic accent per member, from the design palette tokens.
|
||||
const avatarPalette = [
|
||||
'var(--c-purple)',
|
||||
'var(--c-coral)',
|
||||
'var(--c-online)',
|
||||
'var(--accent-ink)'
|
||||
]
|
||||
const avatarColor = (user: User) => {
|
||||
let hash = 0
|
||||
for (const ch of user.id || user.full_name || '') {
|
||||
hash = (hash * 31 + ch.charCodeAt(0)) >>> 0
|
||||
}
|
||||
return avatarPalette[hash % avatarPalette.length]
|
||||
}
|
||||
|
||||
onMounted(fetchGroups)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="group-list">
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">加载团队…</div>
|
||||
|
||||
<div v-else class="teams-grid">
|
||||
<div v-for="group in filteredGroups" :key="group.id" class="team-card">
|
||||
<div class="card-header">
|
||||
<div class="team-name">{{ group.name }}</div>
|
||||
<span class="online-badge" :class="{ active: onlineCount(group) > 0 }">
|
||||
<span class="online-dot"></span>
|
||||
<span class="online-text">{{ onlineCount(group) }} 人在线</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="group.description" class="team-desc">{{ group.description }}</p>
|
||||
<p v-else class="team-desc no-desc">暂无描述</p>
|
||||
|
||||
<div class="card-footer">
|
||||
<div class="members">
|
||||
<div class="avatar-stack">
|
||||
<div
|
||||
v-for="(member, i) in stackedMembers(group)"
|
||||
:key="member.id"
|
||||
class="avatar"
|
||||
:style="{ background: avatarColor(member), marginLeft: i ? '-9px' : '0' }"
|
||||
:title="member.full_name"
|
||||
>
|
||||
{{ initials(member) }}
|
||||
</div>
|
||||
</div>
|
||||
<span class="member-count">
|
||||
{{ memberCount(group) }} 位客服
|
||||
</span>
|
||||
</div>
|
||||
<button class="manage-btn" @click="handleManageMembers(group)">管理</button>
|
||||
</div>
|
||||
|
||||
<!-- Hidden edit/delete affordances preserved on the card via context actions -->
|
||||
<div class="card-actions">
|
||||
<button class="card-action" title="编辑团队" @click="handleEditGroup(group)">编辑</button>
|
||||
<button class="card-action delete" title="删除团队" @click="handleDeleteGroup(group)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create a team (dashed) card -->
|
||||
<button class="create-card" @click="showCreateModal = true">
|
||||
<span class="create-icon">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="create-title">创建团队</span>
|
||||
<span class="create-sub">按技能或渠道给客服分组</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Create Group Modal -->
|
||||
<Modal v-if="showCreateModal" @close="showCreateModal = false">
|
||||
<template #title>创建团队</template>
|
||||
<template #content>
|
||||
<GroupForm
|
||||
@submit="onCreateGroup"
|
||||
@cancel="showCreateModal = false"
|
||||
/>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Edit Group Modal -->
|
||||
<Modal v-if="showEditModal" @close="showEditModal = false">
|
||||
<template #title>编辑团队</template>
|
||||
<template #content>
|
||||
<GroupForm
|
||||
:group="selectedGroup"
|
||||
@submit="handleUpdateGroup"
|
||||
@cancel="showEditModal = false"
|
||||
/>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Manage Members Modal -->
|
||||
<Modal v-if="showMembersModal" @close="showMembersModal = false">
|
||||
<template #title>管理成员</template>
|
||||
<template #content>
|
||||
<div class="members-list">
|
||||
<div v-for="user in users" :key="user.id" class="member-item">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedUsers.includes(user.id)"
|
||||
@change="handleUserSelection(user.id, ($event.target as HTMLInputElement).checked)"
|
||||
:disabled="loading"
|
||||
/>
|
||||
<div class="user-info">
|
||||
<span class="user-name">{{ user.full_name }}</span>
|
||||
<span class="user-email">{{ user.email }}</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<Modal v-if="showDeleteModal" @close="showDeleteModal = false">
|
||||
<template #title>删除团队</template>
|
||||
<template #content>
|
||||
<div class="delete-confirmation">
|
||||
<p>确定要删除“{{ selectedGroup?.name }}”吗?</p>
|
||||
<p class="warning">此操作无法撤销。</p>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-secondary" @click="showDeleteModal = false">
|
||||
取消
|
||||
</button>
|
||||
<button class="btn btn-danger" @click="onDeleteConfirm">
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.group-list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.teams-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
/* ── Team card ───────────────────────────────────────── */
|
||||
.team-card {
|
||||
position: relative;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 18px;
|
||||
padding: 22px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.team-card:hover {
|
||||
border-color: var(--o14);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.team-name {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 17.5px;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.online-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o10);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.online-badge.active {
|
||||
background: var(--teal-bg);
|
||||
border: 1px solid var(--teal-border);
|
||||
}
|
||||
|
||||
.online-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.online-badge.active .online-dot {
|
||||
background: var(--c-online);
|
||||
box-shadow: 0 0 6px var(--c-online);
|
||||
}
|
||||
|
||||
.online-text {
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.online-badge.active .online-text {
|
||||
color: var(--c-online);
|
||||
}
|
||||
|
||||
.team-desc {
|
||||
font-size: 13.5px;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
margin: 12px 0 16px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.no-desc {
|
||||
opacity: 0.7;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Footer ──────────────────────────────────────────── */
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--o07);
|
||||
}
|
||||
|
||||
.members {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
}
|
||||
|
||||
.avatar-stack {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 11.5px;
|
||||
color: var(--on-accent-solid);
|
||||
border: 2px solid var(--surface);
|
||||
}
|
||||
|
||||
.member-count {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.manage-btn {
|
||||
padding: 9px 16px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o14);
|
||||
border-radius: var(--radius-chip);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.manage-btn:hover {
|
||||
background: var(--o10);
|
||||
}
|
||||
|
||||
/* Edit / delete affordances revealed on hover */
|
||||
.card-actions {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.team-card:hover .card-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.card-action {
|
||||
padding: 3px 9px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-chip);
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o10);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.card-action:hover {
|
||||
background: var(--o10);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.card-action.delete:hover {
|
||||
color: var(--c-coral);
|
||||
border-color: var(--c-coral);
|
||||
}
|
||||
|
||||
/* ── Create a team card ──────────────────────────────── */
|
||||
.create-card {
|
||||
background: transparent;
|
||||
border: 1.5px dashed var(--o16);
|
||||
border-radius: 18px;
|
||||
padding: 24px;
|
||||
min-height: 210px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
color: var(--muted2);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.create-card:hover {
|
||||
border-color: var(--c-lime);
|
||||
color: var(--c-lime);
|
||||
}
|
||||
|
||||
.create-icon {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 12px;
|
||||
background: var(--o05);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.create-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.create-sub {
|
||||
font-size: 12.5px;
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
/* ── States ──────────────────────────────────────────── */
|
||||
.error-message {
|
||||
color: var(--error-color);
|
||||
text-align: center;
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: var(--space-xl);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ── Manage-members modal ────────────────────────────── */
|
||||
.members-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
margin: var(--space-md) 0;
|
||||
}
|
||||
|
||||
.member-item {
|
||||
padding: var(--space-sm);
|
||||
border-bottom: 1px solid var(--o08);
|
||||
}
|
||||
|
||||
.member-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.user-email {
|
||||
font-size: var(--text-sm);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ── Delete-confirm modal ────────────────────────────── */
|
||||
.delete-confirmation {
|
||||
text-align: center;
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.delete-confirmation p {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--error-color);
|
||||
font-size: var(--text-sm);
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-md);
|
||||
margin-top: var(--space-xl);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 138, 115, 0.3);
|
||||
color: var(--c-coral);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: rgba(255, 138, 115, 0.1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,181 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { User } from '@/types/user'
|
||||
import { meetsPasswordPolicy, validatePassword, type PasswordStrength } from '@/utils/validators'
|
||||
import PasswordStrengthMeter from '@/components/common/PasswordStrengthMeter.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
user: User
|
||||
submitting?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [newPassword: string]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const passwordTouched = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const passwordStrength = ref<PasswordStrength>({
|
||||
score: 0,
|
||||
hasMinLength: false,
|
||||
hasUpperCase: false,
|
||||
hasLowerCase: false,
|
||||
hasNumber: false,
|
||||
hasSpecialChar: false,
|
||||
})
|
||||
|
||||
const handlePasswordInput = () => {
|
||||
passwordTouched.value = passwordTouched.value || password.value.length > 0
|
||||
passwordStrength.value = validatePassword(password.value)
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
const handleConfirmPasswordInput = () => {
|
||||
error.value =
|
||||
confirmPassword.value && password.value !== confirmPassword.value
|
||||
? '两次输入的密码不一致'
|
||||
: ''
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (password.value !== confirmPassword.value) {
|
||||
error.value = '两次输入的密码不一致'
|
||||
return
|
||||
}
|
||||
if (!meetsPasswordPolicy(passwordStrength.value)) {
|
||||
error.value = '密码强度不足'
|
||||
return
|
||||
}
|
||||
|
||||
emit('submit', password.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="handleSubmit" class="reset-password-form">
|
||||
<p class="intro">
|
||||
为 <strong>{{ props.user.full_name }}</strong>
|
||||
({{ props.user.email }})设置新密码。对方不会收到通知——请直接转告,
|
||||
并请其在登录后自行修改。
|
||||
</p>
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="newPassword">新密码</label>
|
||||
<input
|
||||
id="newPassword"
|
||||
v-model="password"
|
||||
type="password"
|
||||
required
|
||||
class="form-input"
|
||||
autocomplete="new-password"
|
||||
@input="handlePasswordInput"
|
||||
/>
|
||||
<PasswordStrengthMeter v-if="passwordTouched" :strength="passwordStrength" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirmNewPassword">确认新密码</label>
|
||||
<input
|
||||
id="confirmNewPassword"
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
class="form-input"
|
||||
autocomplete="new-password"
|
||||
:class="{ error: error.includes('不一致') }"
|
||||
@input="handleConfirmPasswordInput"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="emit('cancel')">
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="props.submitting">
|
||||
{{ props.submitting ? '重置中…' : '重置密码' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.reset-password-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.intro {
|
||||
margin: 0;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
/* Matches UserForm: the global .form-group margin plus the flex gap above
|
||||
would otherwise double the spacing between fields. */
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 13.5px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--error-color);
|
||||
font-size: var(--text-sm);
|
||||
padding: var(--space-sm);
|
||||
background: var(--error-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.form-input.error {
|
||||
border-color: var(--error-color);
|
||||
}
|
||||
|
||||
.form-input.error:focus {
|
||||
box-shadow: 0 0 0 2px var(--error-soft);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,298 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import type { Role } from '@/types/user'
|
||||
import { listPermissions, type Permission } from '@/services/roles'
|
||||
|
||||
const props = defineProps<{
|
||||
role?: Role | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [roleData: Partial<Role>]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const name = ref('')
|
||||
const description = ref('')
|
||||
const selectedPermissions = ref<Permission[]>([])
|
||||
const permissions = ref<Permission[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// Watch for role prop changes to update form
|
||||
watch(() => props.role, (newRole) => {
|
||||
name.value = newRole?.name || ''
|
||||
description.value = newRole?.description || ''
|
||||
selectedPermissions.value = newRole?.permissions || []
|
||||
}, { immediate: true })
|
||||
|
||||
const fetchPermissions = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
permissions.value = await listPermissions()
|
||||
} catch (err) {
|
||||
console.error('Failed to load permissions:', err)
|
||||
error.value = '加载权限失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!selectedPermissions.value.length) {
|
||||
error.value = '请至少选择一项权限'
|
||||
return
|
||||
}
|
||||
|
||||
emit('submit', {
|
||||
name: name.value,
|
||||
description: description.value,
|
||||
is_default: false,
|
||||
permissions: selectedPermissions.value
|
||||
})
|
||||
|
||||
// Reset form if not editing
|
||||
if (!props.role) {
|
||||
name.value = ''
|
||||
description.value = ''
|
||||
selectedPermissions.value = []
|
||||
error.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchPermissions)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="handleSubmit" class="role-form">
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="name">名称</label>
|
||||
<input
|
||||
id="name"
|
||||
v-model="name"
|
||||
type="text"
|
||||
required
|
||||
class="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">描述</label>
|
||||
<textarea
|
||||
id="description"
|
||||
v-model="description"
|
||||
class="form-input"
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>权限</label>
|
||||
<div v-if="loading" class="loading">加载权限…</div>
|
||||
<div v-else class="permissions-list">
|
||||
<label
|
||||
v-for="permission in permissions"
|
||||
:key="permission.id"
|
||||
class="permission-item"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedPermissions.map(p => p.id).includes(permission.id)"
|
||||
@change="($event) => {
|
||||
if (($event.target as HTMLInputElement).checked) {
|
||||
selectedPermissions.push(permission)
|
||||
} else {
|
||||
selectedPermissions = selectedPermissions.filter(p => p.id !== permission.id)
|
||||
}
|
||||
}"
|
||||
/>
|
||||
<div class="permission-info">
|
||||
<span class="permission-name">{{ permission.name }}</span>
|
||||
<span v-if="permission.description" class="permission-description">
|
||||
{{ permission.description }}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="emit('cancel')">
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{{ props.role ? '更新' : '创建' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.role-form {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 9px;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 13px 15px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: var(--radius-input);
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14.5px;
|
||||
}
|
||||
|
||||
textarea.form-input {
|
||||
resize: vertical;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.form-input::placeholder {
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-ink);
|
||||
box-shadow: var(--ring-focus);
|
||||
}
|
||||
|
||||
.permissions-list {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: var(--radius-input);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.permission-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 9px 11px;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.permission-item:hover {
|
||||
background: var(--o04);
|
||||
}
|
||||
|
||||
.permission-item input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 1px 0 0;
|
||||
flex-shrink: 0;
|
||||
accent-color: var(--accent-ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.permission-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.permission-name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text2);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.permission-description {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 22px;
|
||||
border-radius: var(--radius-btn);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast), filter var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o14);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--o10);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--error-color);
|
||||
margin-bottom: var(--space-md);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: var(--space-lg);
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,731 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
import RoleForm from './RoleForm.vue'
|
||||
import type { Role } from '@/types/user'
|
||||
import { useRoles } from '@/composables/useRoles'
|
||||
import { useSubscriptionStorage } from '@/utils/storage'
|
||||
import { useEnterpriseFeatures } from '@/composables/useEnterpriseFeatures'
|
||||
|
||||
const props = defineProps<{
|
||||
searchQuery?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'changed'): void
|
||||
}>()
|
||||
|
||||
const {
|
||||
roles,
|
||||
loading,
|
||||
error,
|
||||
showCreateModal,
|
||||
showEditModal,
|
||||
showDeleteModal,
|
||||
selectedRole,
|
||||
fetchRoles,
|
||||
handleCreateRole,
|
||||
handleEditRole,
|
||||
handleUpdateRole,
|
||||
handleDeleteRole,
|
||||
confirmDeleteRole,
|
||||
openCreateModal,
|
||||
deleteError
|
||||
} = useRoles()
|
||||
|
||||
// Wrap create/delete so the parent view can refresh counts after a change
|
||||
const createRoleAndNotify = async (data: Partial<Role>) => {
|
||||
await handleCreateRole(data)
|
||||
if (!showCreateModal.value) {
|
||||
emit('changed')
|
||||
}
|
||||
}
|
||||
|
||||
const deleteRoleAndNotify = async () => {
|
||||
await confirmDeleteRole()
|
||||
if (!showDeleteModal.value) {
|
||||
emit('changed')
|
||||
}
|
||||
}
|
||||
|
||||
// Filter cards by role name (case-insensitive)
|
||||
const filteredRoles = computed(() => {
|
||||
const q = (props.searchQuery || '').trim().toLowerCase()
|
||||
if (!q) return roles.value
|
||||
return roles.value.filter(role => role.name?.toLowerCase().includes(q))
|
||||
})
|
||||
|
||||
// Subscription and role feature checking
|
||||
const subscriptionStorage = useSubscriptionStorage()
|
||||
const { hasEnterpriseModule } = useEnterpriseFeatures()
|
||||
const currentSubscription = computed(() => subscriptionStorage.getCurrentSubscription())
|
||||
const isSubscriptionActive = computed(() => subscriptionStorage.isSubscriptionActive())
|
||||
|
||||
// Check if roles is locked (only if enterprise module exists)
|
||||
const isRolesLocked = computed(() => {
|
||||
// Only lock if enterprise module exists
|
||||
if (!hasEnterpriseModule) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!currentSubscription.value || !isSubscriptionActive.value) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if role feature exists in subscription
|
||||
const hasRoleFeature = subscriptionStorage.hasFeature('role')
|
||||
if (!hasRoleFeature) {
|
||||
return true // Lock roles if feature doesn't exist
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
// Upgrade modal functions
|
||||
const handleUpgrade = () => {
|
||||
// Only redirect to subscription page if enterprise module exists
|
||||
if (hasEnterpriseModule) {
|
||||
window.location.href = '/settings/subscription'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!isRolesLocked.value) {
|
||||
fetchRoles()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="role-list">
|
||||
<!-- Roles Locked Overlay -->
|
||||
<div v-if="isRolesLocked" class="roles-locked-overlay">
|
||||
<div class="locked-content">
|
||||
<div class="locked-header">
|
||||
<div class="locked-icon-wrapper">
|
||||
<div class="locked-icon-bg">
|
||||
<font-awesome-icon icon="fa-solid fa-users-gear" class="locked-icon" />
|
||||
</div>
|
||||
</div>
|
||||
<h2>角色管理</h2>
|
||||
<div class="locked-badge">
|
||||
<font-awesome-icon icon="fa-solid fa-lock" class="badge-icon" />
|
||||
<span>高级功能</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="locked-description">
|
||||
解锁高级角色管理,掌控用户权限,
|
||||
创建自定义角色,并管理团队访问级别。
|
||||
</p>
|
||||
|
||||
<div class="locked-features">
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon-wrapper">
|
||||
<font-awesome-icon icon="fa-solid fa-user-shield" class="feature-icon" />
|
||||
</div>
|
||||
<div class="feature-content">
|
||||
<span class="feature-title">自定义角色</span>
|
||||
<span class="feature-desc">创建并管理自定义用户角色</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<div class="feature-icon-wrapper">
|
||||
<font-awesome-icon icon="fa-solid fa-key" class="feature-icon" />
|
||||
</div>
|
||||
<div class="feature-content">
|
||||
<span class="feature-title">权限控制</span>
|
||||
<span class="feature-desc">精细化的访问权限管理</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="upgrade-section">
|
||||
<button class="upgrade-button" @click="handleUpgrade">
|
||||
<font-awesome-icon icon="fa-solid fa-crown" class="upgrade-icon" />
|
||||
<span>升级以解锁角色</span>
|
||||
<font-awesome-icon icon="fa-solid fa-arrow-right" class="arrow-icon" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Roles Content (when unlocked) -->
|
||||
<div v-else>
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">加载角色…</div>
|
||||
|
||||
<div v-else class="roles-grid">
|
||||
<div
|
||||
v-for="role in filteredRoles"
|
||||
:key="role.id"
|
||||
class="role-card"
|
||||
>
|
||||
<div class="role-card__header">
|
||||
<h3 class="role-card__name">{{ role.name }}</h3>
|
||||
<span v-if="role.is_default" class="role-card__badge">默认</span>
|
||||
</div>
|
||||
|
||||
<p class="role-card__desc" :class="{ 'is-muted': !role.description }">
|
||||
{{ role.description || '暂无描述' }}
|
||||
</p>
|
||||
|
||||
<template v-if="role.permissions?.length">
|
||||
<div class="role-card__perm-label">权限</div>
|
||||
<div class="role-card__perm-chips">
|
||||
<span
|
||||
v-for="permission in role.permissions"
|
||||
:key="permission.id"
|
||||
class="role-card__chip"
|
||||
>
|
||||
{{ permission.name }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<button
|
||||
class="role-card__edit"
|
||||
@click="handleEditRole(role)"
|
||||
:disabled="role.is_default"
|
||||
:title="role.is_default ? '无法编辑默认角色' : ''"
|
||||
>
|
||||
编辑权限
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="!role.is_default"
|
||||
class="role-card__delete"
|
||||
@click="handleDeleteRole(role)"
|
||||
>
|
||||
删除角色
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Create a custom role card -->
|
||||
<button class="role-card-add" @click="openCreateModal">
|
||||
<div class="role-card-add__icon">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="role-card-add__title">创建自定义角色</div>
|
||||
<div class="role-card-add__desc">精确指定一组客服可获得的权限</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Role Modal -->
|
||||
<Modal v-if="showCreateModal || showEditModal"
|
||||
@close="showCreateModal ? (showCreateModal = false) : (showEditModal = false)">
|
||||
<template #title>{{ showCreateModal ? '创建角色' : '编辑角色' }}</template>
|
||||
<template #content>
|
||||
<RoleForm
|
||||
:role="selectedRole"
|
||||
@submit="(data) => showCreateModal ? createRoleAndNotify(data) : handleUpdateRole(data)"
|
||||
@cancel="showCreateModal ? (showCreateModal = false) : (showEditModal = false)"
|
||||
/>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<Modal v-if="showDeleteModal" @close="showDeleteModal = false">
|
||||
<template #title>删除角色</template>
|
||||
<template #content>
|
||||
<div class="delete-confirmation">
|
||||
<div v-if="deleteError" class="error-message">
|
||||
{{ deleteError }}
|
||||
</div>
|
||||
<p>确定要删除“{{ selectedRole?.name }}”吗?</p>
|
||||
<p class="warning">此操作无法撤销。</p>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-secondary" @click="showDeleteModal = false">
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
@click="deleteRoleAndNotify"
|
||||
:disabled="loading"
|
||||
>
|
||||
{{ loading ? '删除中…' : '删除' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.role-list {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.roles-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.role-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 18px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.role-card:hover {
|
||||
border-color: var(--o16);
|
||||
}
|
||||
|
||||
.role-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.role-card__name {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 19px;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.role-card__badge {
|
||||
padding: 2px 9px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--accent-bg-12);
|
||||
border: 1px solid var(--accent-border);
|
||||
color: var(--accent-ink);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.role-card__desc {
|
||||
font-size: 13.5px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
|
||||
.role-card__desc.is-muted {
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.role-card__perm-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--faint);
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
|
||||
.role-card__perm-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.role-card__chip {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11.5px;
|
||||
padding: 4px 9px;
|
||||
border-radius: 7px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o08);
|
||||
color: var(--text3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.role-card__edit {
|
||||
margin-top: 20px;
|
||||
padding: 11px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o14);
|
||||
border-radius: var(--radius-chip);
|
||||
color: var(--text);
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.role-card__edit:hover:not(:disabled) {
|
||||
background: var(--o10);
|
||||
}
|
||||
|
||||
.role-card__edit:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.role-card__delete {
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted2);
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.role-card__delete:hover {
|
||||
color: var(--c-coral);
|
||||
}
|
||||
|
||||
/* Create a custom role dashed card */
|
||||
.role-card-add {
|
||||
background: transparent;
|
||||
border: 1.5px dashed var(--o16);
|
||||
border-radius: 18px;
|
||||
padding: 24px;
|
||||
min-height: 240px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
color: var(--muted2);
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.role-card-add:hover {
|
||||
border-color: var(--accent-border);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.role-card-add__icon {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 12px;
|
||||
background: var(--o05);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.role-card-add__title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.role-card-add__desc {
|
||||
font-size: 12.5px;
|
||||
color: var(--muted2);
|
||||
text-align: center;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: var(--space-xl);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--c-coral);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.delete-confirmation {
|
||||
text-align: center;
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.delete-confirmation p {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-md);
|
||||
margin-top: var(--space-xl);
|
||||
}
|
||||
|
||||
/* Roles Locked Overlay Styles */
|
||||
.roles-locked-overlay {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 50vh;
|
||||
background: var(--surface);
|
||||
border-radius: 20px;
|
||||
margin: var(--space-md) 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--o08);
|
||||
}
|
||||
|
||||
.roles-locked-overlay::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 80%, rgba(201, 242, 78, 0.04) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, rgba(16, 185, 129, 0.05) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.locked-content {
|
||||
text-align: center;
|
||||
max-width: 700px;
|
||||
padding: var(--space-xl) var(--space-md);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.locked-header {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.locked-icon-wrapper {
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.locked-icon-bg {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: rgba(201, 242, 78, 0.15);
|
||||
border: 1px solid rgba(201, 242, 78, 0.3);
|
||||
border-radius: 50%;
|
||||
box-shadow: var(--shadow-lg);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.locked-icon {
|
||||
font-size: 1.5rem;
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.locked-content h2 {
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: 700;
|
||||
font-family: var(--font-display);
|
||||
color: var(--text);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.locked-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
background: rgba(201, 242, 78, 0.12);
|
||||
color: var(--accent-ink);
|
||||
border: 1px solid rgba(201, 242, 78, 0.3);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-icon {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.locked-description {
|
||||
font-size: var(--text-lg);
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
margin-bottom: var(--space-lg);
|
||||
max-width: 600px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.locked-features {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-lg);
|
||||
background: var(--o05);
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--o10);
|
||||
text-align: left;
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.feature-item:hover {
|
||||
transform: translateY(-2px);
|
||||
background: var(--o08);
|
||||
border-color: var(--o16);
|
||||
}
|
||||
|
||||
.feature-icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: rgba(201, 242, 78, 0.12);
|
||||
border-radius: var(--radius-md);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 1rem;
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.feature-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.feature-desc {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.upgrade-section {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.upgrade-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
padding: var(--space-md) var(--space-xl);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-normal);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.upgrade-button:hover {
|
||||
opacity: 0.88;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(201, 242, 78, 0.25);
|
||||
}
|
||||
|
||||
.upgrade-icon {
|
||||
font-size: 1rem;
|
||||
color: var(--on-accent-solid);
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
font-size: 0.875rem;
|
||||
transition: transform var(--transition-normal);
|
||||
}
|
||||
|
||||
.upgrade-button:hover .arrow-icon {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
/* Responsive adjustments for locked overlay */
|
||||
@media (max-width: 768px) {
|
||||
.roles-locked-overlay {
|
||||
min-height: 50vh;
|
||||
margin: var(--space-md) 0;
|
||||
}
|
||||
|
||||
.locked-content {
|
||||
padding: var(--space-xl) var(--space-md);
|
||||
}
|
||||
|
||||
.locked-content h2 {
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
|
||||
.locked-description {
|
||||
font-size: var(--text-base);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.locked-features {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.feature-icon-wrapper {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.upgrade-button {
|
||||
width: 100%;
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.locked-icon-bg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.locked-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.locked-header {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,367 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, watch } from 'vue'
|
||||
import type { ChatScopeFields, Role, User } from '@/types/user'
|
||||
import { meetsPasswordPolicy, validatePassword, type PasswordStrength } from '@/utils/validators'
|
||||
import { listRoles } from '@/services/roles'
|
||||
import { AI_QUEUE_PERMISSION, ALL_CHATS_PERMISSION } from '@/utils/permissionGroups'
|
||||
import PasswordStrengthMeter from '@/components/common/PasswordStrengthMeter.vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
const props = defineProps<{
|
||||
user?: User | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [userData: Partial<User> & ChatScopeFields & { password?: string, role_id?: number }]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const fullName = ref(props.user?.full_name || '')
|
||||
const email = ref(props.user?.email || '')
|
||||
const isActive = ref(props.user?.is_active ?? true)
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const showPasswordFields = ref(!props.user)
|
||||
const passwordTouched = ref(false)
|
||||
const error = ref('')
|
||||
const roles = ref<Role[]>([])
|
||||
const selectedRole = ref(props.user?.role?.id || '')
|
||||
const loadingRoles = ref(false)
|
||||
|
||||
// Chat scope. A new agent starts able to pick up whatever the AI is still
|
||||
// handling, and unable to read colleagues' conversations — tick the second one
|
||||
// to let someone cover a teammate's inbox. Once roles load these re-derive from
|
||||
// whichever role is selected, so they always show what that role actually does.
|
||||
const seeAllAiChats = ref(true)
|
||||
const seeAllOrgChats = ref(false)
|
||||
|
||||
const selectedRoleObject = computed(
|
||||
() => roles.value.find(role => String(role.id) === String(selectedRole.value))
|
||||
)
|
||||
|
||||
const permissionsOfSelectedRole = computed(
|
||||
() => selectedRoleObject.value?.permissions?.map(permission => permission.name) ?? []
|
||||
)
|
||||
|
||||
// super_admin passes every check, so the toggles would be decorative.
|
||||
const scopeApplies = computed(
|
||||
() => !permissionsOfSelectedRole.value.includes('super_admin')
|
||||
)
|
||||
|
||||
const syncScopeFromRole = () => {
|
||||
// Only bail when the role itself is missing — still loading, or nothing
|
||||
// picked yet. A role that exists and grants nothing should untick both,
|
||||
// which testing for an empty permission list would get wrong.
|
||||
if (!selectedRoleObject.value) return
|
||||
const permissions = permissionsOfSelectedRole.value
|
||||
seeAllAiChats.value = permissions.includes(AI_QUEUE_PERMISSION)
|
||||
seeAllOrgChats.value = permissions.includes(ALL_CHATS_PERMISSION)
|
||||
}
|
||||
|
||||
watch(selectedRole, syncScopeFromRole)
|
||||
|
||||
const passwordStrength = ref<PasswordStrength>({
|
||||
score: 0,
|
||||
hasMinLength: false,
|
||||
hasUpperCase: false,
|
||||
hasLowerCase: false,
|
||||
hasNumber: false,
|
||||
hasSpecialChar: false
|
||||
})
|
||||
|
||||
const handlePasswordInput = (password: string) => {
|
||||
if (!passwordTouched.value && password.length > 0) {
|
||||
passwordTouched.value = true
|
||||
}
|
||||
passwordStrength.value = validatePassword(password)
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
const handleConfirmPasswordInput = () => {
|
||||
if (confirmPassword.value && password.value !== confirmPassword.value) {
|
||||
error.value = '两次输入的密码不一致'
|
||||
} else {
|
||||
error.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const fetchRoles = async () => {
|
||||
try {
|
||||
loadingRoles.value = true
|
||||
roles.value = await listRoles()
|
||||
syncScopeFromRole()
|
||||
} catch (err) {
|
||||
console.error('Error loading roles:', err)
|
||||
toast.error('错误', {
|
||||
description: '加载角色失败',
|
||||
duration: 4000,
|
||||
closeButton: true
|
||||
})
|
||||
} finally {
|
||||
loadingRoles.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchRoles)
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (showPasswordFields.value) {
|
||||
if (password.value !== confirmPassword.value) {
|
||||
error.value = '两次输入的密码不一致'
|
||||
return
|
||||
}
|
||||
// The initial password an admin picks clears the same bar as a reset —
|
||||
// three surfaces used to disagree on what "strong enough" meant.
|
||||
if (!meetsPasswordPolicy(passwordStrength.value)) {
|
||||
error.value = '密码强度不足'
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedRole.value) {
|
||||
error.value = '请选择角色'
|
||||
return
|
||||
}
|
||||
|
||||
const userData = {
|
||||
full_name: fullName.value,
|
||||
email: email.value,
|
||||
is_active: isActive.value,
|
||||
role_id: Number(selectedRole.value),
|
||||
...(scopeApplies.value && {
|
||||
see_all_ai_chats: seeAllAiChats.value,
|
||||
see_all_org_chats: seeAllOrgChats.value
|
||||
}),
|
||||
...(showPasswordFields.value && { password: password.value })
|
||||
}
|
||||
|
||||
emit('submit', userData)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="handleSubmit" class="user-form">
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="fullName">姓名</label>
|
||||
<input
|
||||
id="fullName"
|
||||
v-model="fullName"
|
||||
type="text"
|
||||
required
|
||||
class="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">邮箱</label>
|
||||
<input
|
||||
id="email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
required
|
||||
class="form-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="role">角色</label>
|
||||
<select
|
||||
id="role"
|
||||
v-model="selectedRole"
|
||||
required
|
||||
class="form-input"
|
||||
:disabled="loadingRoles"
|
||||
>
|
||||
<option value="" disabled>选择角色</option>
|
||||
<option
|
||||
v-for="role in roles"
|
||||
:key="role.id"
|
||||
:value="role.id"
|
||||
>
|
||||
{{ role.name }}
|
||||
</option>
|
||||
</select>
|
||||
<span v-if="loadingRoles" class="loading-text">加载角色…</span>
|
||||
</div>
|
||||
|
||||
<div v-if="scopeApplies" class="form-group">
|
||||
<label>可以查看哪些会话?</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="seeAllAiChats" />
|
||||
<span>
|
||||
全部 AI 会话
|
||||
<small>AI 正在处理、尚无人接手的会话。</small>
|
||||
</span>
|
||||
</label>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" v-model="seeAllOrgChats" />
|
||||
<span>
|
||||
组织内全部会话
|
||||
<small>包含其他人的会话——适合在同事休假时代为处理。</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<template v-if="showPasswordFields">
|
||||
<div class="form-group">
|
||||
<label for="password">密码</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
required
|
||||
class="form-input"
|
||||
autocomplete="new-password"
|
||||
@input="handlePasswordInput(password)"
|
||||
/>
|
||||
<PasswordStrengthMeter v-if="passwordTouched" :strength="passwordStrength" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirmPassword">确认密码</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
class="form-input"
|
||||
autocomplete="new-password"
|
||||
@input="handleConfirmPasswordInput"
|
||||
:class="{ 'error': error && error.includes('不一致') }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="isActive"
|
||||
/>
|
||||
启用账号
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" @click="emit('cancel')">
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{{ props.user ? '更新' : '创建' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
/* The global .form-group already adds margin-bottom: var(--space-lg); combined
|
||||
with the flex gap above that doubled the spacing between every field. Zero
|
||||
it and let the single flex gap own the vertical rhythm. */
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 13.5px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
/* Inputs deliberately inherit the app-wide .form-input styling from
|
||||
components.css (padding, radius, hover + focus ring) so they match every
|
||||
other form — only the <select> chevron is added below. */
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-sm);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.checkbox-label small {
|
||||
display: block;
|
||||
color: var(--text3);
|
||||
font-size: var(--text-xs);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--error-color);
|
||||
font-size: var(--text-sm);
|
||||
margin-bottom: var(--space-md);
|
||||
padding: var(--space-sm);
|
||||
background: var(--error-soft);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.form-input.error {
|
||||
border-color: var(--error-color);
|
||||
}
|
||||
|
||||
.form-input.error:focus {
|
||||
box-shadow: 0 0 0 2px var(--error-soft);
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
select.form-input {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
|
||||
background-position: right 0.5rem center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 1.5em 1.5em;
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
|
||||
select.form-input:disabled {
|
||||
background-color: var(--background-mute);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import type { User } from '@/types/user'
|
||||
import { listGroups } from '@/services/groups'
|
||||
import type { UserGroup } from '@/types/user'
|
||||
|
||||
const props = defineProps<{
|
||||
user: User
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
groupToggle: [groupId: string, checked: boolean]
|
||||
}>()
|
||||
|
||||
const groups = ref<UserGroup[]>([])
|
||||
const loading = ref(false)
|
||||
const selectedGroups = ref<string[]>(props.user.groups?.map(g => g.id) || [])
|
||||
|
||||
const fetchGroups = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
groups.value = await listGroups()
|
||||
} catch (err) {
|
||||
console.error('Failed to load groups:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchGroups)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="groups-form">
|
||||
<div v-if="loading" class="loading">加载团队…</div>
|
||||
<div v-else class="groups-list">
|
||||
<div v-for="group in groups" :key="group.id" class="group-item">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedGroups.includes(group.id)"
|
||||
@change="$emit('groupToggle', group.id, ($event.target as HTMLInputElement).checked)"
|
||||
:disabled="loading"
|
||||
/>
|
||||
<div class="group-info">
|
||||
<span class="group-name">{{ group.name }}</span>
|
||||
<span v-if="group.description" class="group-description">{{ group.description }}</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.groups-form {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.groups-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.group-item {
|
||||
padding: var(--space-sm);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.group-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.group-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.group-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.group-description {
|
||||
font-size: var(--text-sm);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: var(--space-xl);
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,352 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts" name="AppSidebar">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useNavItems, navIconSvg } from './navItems'
|
||||
|
||||
import SidebarToggle from './SidebarToggle.vue'
|
||||
|
||||
defineProps<{
|
||||
isCollapsed: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggle'): void
|
||||
(e: 'navigate'): void
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const { navItems } = useNavItems()
|
||||
|
||||
const isActiveRoute = computed(() => (path?: string) => path ? route.path === path : false)
|
||||
|
||||
const handleNavigation = () => {
|
||||
emit('navigate')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="sidebar" :class="{ 'collapsed': isCollapsed }">
|
||||
<!-- Logo -->
|
||||
<div class="sidebar-header">
|
||||
<div class="logo-container">
|
||||
<div class="logo-mark" aria-hidden="true">
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
</div>
|
||||
<span v-if="!isCollapsed" class="logo-text">ChatterMate</span>
|
||||
</div>
|
||||
<SidebarToggle :isCollapsed="isCollapsed" @toggle="emit('toggle')" />
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="sidebar-nav">
|
||||
<div v-for="(item, index) in navItems" :key="index">
|
||||
<!-- Section Header -->
|
||||
<div v-if="item.section" class="nav-section nav-section-heading" :class="{ 'collapsed': isCollapsed }">
|
||||
<span v-if="!isCollapsed">{{ item.section }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Nav Item -->
|
||||
<router-link v-else-if="item.to" :to="item.to" class="nav-item"
|
||||
:class="{ 'active': isActiveRoute(item.to) }"
|
||||
:title="isCollapsed ? item.label : undefined"
|
||||
@click="handleNavigation">
|
||||
<span class="nav-bar" aria-hidden="true"></span>
|
||||
<span class="nav-icon" v-html="navIconSvg(item.icon)"></span>
|
||||
<span v-if="!isCollapsed" class="nav-label">{{ item.label }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
width: 252px;
|
||||
background: var(--bg2);
|
||||
border-right: 1px solid var(--o07);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: width .22s ease;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: 76px;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 20px 14px;
|
||||
border-bottom: 1px solid var(--o06);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Collapsed header: stack logo above the toggle, both centered */
|
||||
.sidebar.collapsed .sidebar-header {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 14px;
|
||||
padding: 18px 0;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 3-dot logo mark */
|
||||
.logo-mark {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--accent-solid);
|
||||
border-radius: 10px 10px 10px 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 4.5px;
|
||||
height: 4.5px;
|
||||
background: var(--on-accent);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-family: var(--font-display);
|
||||
font-weight: var(--font-weight-bold);
|
||||
letter-spacing: var(--tracking-display);
|
||||
font-size: 18px;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 18px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
/* Hide the scrollbar so it doesn't squeeze the centered icons when collapsed */
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.sidebar-nav::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Collapsed: tighten side padding so icons sit dead-centre in the 76px rail */
|
||||
.sidebar.collapsed .sidebar-nav {
|
||||
padding: 18px 8px;
|
||||
}
|
||||
|
||||
/* Typography comes from the shared .nav-section-heading (components.css) */
|
||||
.nav-section {
|
||||
padding: 0 13px;
|
||||
margin: 4px 0 12px;
|
||||
}
|
||||
|
||||
/* Collapsed: section label becomes an invisible spacer, no divider line */
|
||||
.nav-section.collapsed {
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
padding: 11px 13px;
|
||||
margin-bottom: 4px;
|
||||
color: var(--muted);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14.5px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-decoration: none;
|
||||
border-radius: var(--radius-btn);
|
||||
transition: background-color var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
/* Collapsed: center the icon, drop the gap */
|
||||
.sidebar.collapsed .nav-item {
|
||||
padding: 11px;
|
||||
gap: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-item:hover:not(.active) {
|
||||
background: var(--o04);
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.nav-item:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--ring-focus);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--accent-bg-12);
|
||||
color: var(--accent-ink);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
/* Lime accent bar on the active item */
|
||||
.nav-bar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 9px;
|
||||
bottom: 9px;
|
||||
width: 3px;
|
||||
border-radius: 3px;
|
||||
background: var(--accent-solid);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.nav-item.active .nav-bar {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Hide the active bar when collapsed */
|
||||
.sidebar.collapsed .nav-item.active .nav-bar {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.nav-icon :deep(svg) {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Small laptops (1025px - 1280px) */
|
||||
@media (max-width: 1280px) and (min-width: 1025px) {
|
||||
.sidebar {
|
||||
width: 248px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: 76px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablets and below - Overlay mode */
|
||||
@media (max-width: 1024px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 1000;
|
||||
box-shadow: 8px 0 32px rgba(0,0,0,.4);
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
transform: translateX(-100%);
|
||||
width: 252px;
|
||||
/* An off-canvas panel must not keep painting its shadow over the page.
|
||||
Sliding it out moves the box, not the shadow's reach: with an 8px
|
||||
offset and 32px blur, roughly 24px of it still lands on the page's
|
||||
left edge, above the content at z-index 1000. On the dark theme that
|
||||
is black on near-black and invisible, which is why it went unnoticed;
|
||||
on the light theme it reads as a grey band down the side. */
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* In overlay mode the panel is full-width — restore expanded layout */
|
||||
.sidebar.collapsed .sidebar-header {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 22px 20px;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-item {
|
||||
padding: 11px 13px;
|
||||
gap: 13px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
max-width: 85vw;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: 280px;
|
||||
max-width: 85vw;
|
||||
}
|
||||
}
|
||||
|
||||
/* Very small mobile */
|
||||
@media (max-width: 480px) {
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
}
|
||||
|
||||
.nav-section {
|
||||
padding: var(--space-md) var(--space-lg) var(--space-xs);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,156 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts" name="BottomNav">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useNavItems, navIconSvg, formatBadgeCount } from './navItems'
|
||||
|
||||
const props = defineProps<{
|
||||
unreadCount?: number
|
||||
moreOpen?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'more'): void
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const { primaryNavItems, moreNavItems } = useNavItems()
|
||||
|
||||
const isActive = (path?: string) => !!path && route.path.startsWith(path)
|
||||
|
||||
// More is "active" while its sheet is open or when the current page lives in it
|
||||
const moreActive = computed(() =>
|
||||
props.moreOpen || moreNavItems.value.some(item => isActive(item.to))
|
||||
)
|
||||
|
||||
const badgeText = computed(() => formatBadgeCount(props.unreadCount))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="bottom-nav" aria-label="主导航">
|
||||
<router-link
|
||||
v-for="item in primaryNavItems"
|
||||
:key="item.to"
|
||||
:to="item.to!"
|
||||
class="bottom-nav-item"
|
||||
:class="{ active: isActive(item.to) }"
|
||||
>
|
||||
<span class="bottom-nav-icon" v-html="navIconSvg(item.icon, 24)"></span>
|
||||
<span class="bottom-nav-label">{{ item.label }}</span>
|
||||
</router-link>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="bottom-nav-item"
|
||||
:class="{ active: moreActive }"
|
||||
aria-label="更多"
|
||||
@click="emit('more')"
|
||||
>
|
||||
<span class="bottom-nav-icon" v-html="navIconSvg('more', 24)"></span>
|
||||
<span class="bottom-nav-label">更多</span>
|
||||
<span v-if="unreadCount" class="bottom-nav-badge">{{ badgeText }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bottom-nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: var(--z-bottom-nav);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
background: var(--bg2);
|
||||
border-top: 1px solid var(--o07);
|
||||
padding: 6px 14px calc(6px + var(--safe-bottom));
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-nav-item {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
padding: 2px 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-fast);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.bottom-nav-item.active {
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.bottom-nav-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bottom-nav-icon :deep(svg) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: block;
|
||||
stroke-width: 1.8;
|
||||
}
|
||||
|
||||
.bottom-nav-label {
|
||||
font-size: 11px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.bottom-nav-item.active .bottom-nav-label {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.bottom-nav-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: calc(50% + 6px);
|
||||
min-width: 17px;
|
||||
height: 17px;
|
||||
padding: 0 4px;
|
||||
background: var(--c-danger);
|
||||
color: var(--on-dark);
|
||||
border: 1.5px solid var(--bg2);
|
||||
border-radius: var(--radius-pill);
|
||||
font-family: var(--font-display);
|
||||
font-size: 10px;
|
||||
font-weight: var(--font-weight-bold);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,394 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts" name="MoreSheet">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useNavItems, navIconSvg, formatBadgeCount } from './navItems'
|
||||
import InstallPrompt from '@/components/pwa/InstallPrompt.vue'
|
||||
import type { ThemeMode } from '@/composables/useTheme'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
isOnline?: boolean
|
||||
statusUpdating?: boolean
|
||||
themeMode?: ThemeMode
|
||||
unreadCount?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'toggle-status'): void
|
||||
(e: 'toggle-theme'): void
|
||||
(e: 'notifications'): void
|
||||
(e: 'logout'): void
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const { moreNavGroups } = useNavItems()
|
||||
|
||||
const themeLabel = computed(() =>
|
||||
props.themeMode === 'dark' ? '深色' : props.themeMode === 'light' ? '浅色' : '跟随系统'
|
||||
)
|
||||
|
||||
const themeIconName = computed(() =>
|
||||
props.themeMode === 'dark' ? 'moon' : props.themeMode === 'light' ? 'sun' : 'monitor'
|
||||
)
|
||||
|
||||
const badgeText = computed(() => formatBadgeCount(props.unreadCount))
|
||||
|
||||
const navigate = (to?: string) => {
|
||||
if (!to) return
|
||||
emit('close')
|
||||
router.push(to)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="more-sheet">
|
||||
<div v-if="open" class="more-sheet-root">
|
||||
<div class="more-scrim" @click="emit('close')"></div>
|
||||
<div class="more-sheet" role="dialog" aria-label="更多">
|
||||
<div class="drag-handle" aria-hidden="true"></div>
|
||||
|
||||
<!-- Availability -->
|
||||
<button
|
||||
type="button"
|
||||
class="availability-card"
|
||||
:class="{ offline: !isOnline }"
|
||||
:disabled="statusUpdating"
|
||||
@click="emit('toggle-status')"
|
||||
>
|
||||
<span class="availability-dot" :class="{ online: isOnline }"></span>
|
||||
<span class="availability-text">
|
||||
<span class="availability-title">{{ isOnline ? "你当前在线" : "你当前离线" }}</span>
|
||||
<span class="availability-sub">{{ isOnline ? '接收新会话' : '暂停接收新会话' }}</span>
|
||||
</span>
|
||||
<span class="toggle-track" :class="{ on: isOnline }">
|
||||
<span class="toggle-knob"></span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Theme -->
|
||||
<button type="button" class="sheet-row standalone" @click="emit('toggle-theme')">
|
||||
<span class="row-icon theme-icon" aria-hidden="true" v-html="navIconSvg(themeIconName, 20)"></span>
|
||||
<span class="row-label">主题</span>
|
||||
<span class="row-value">{{ themeLabel }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Overflow nav links, grouped exactly as the desktop sidebar -->
|
||||
<template v-for="(group, index) in moreNavGroups" :key="group.section || index">
|
||||
<div v-if="group.section" class="sheet-section nav-section-heading">{{ group.section }}</div>
|
||||
<div class="sheet-card">
|
||||
<button
|
||||
v-for="item in group.items"
|
||||
:key="item.to"
|
||||
type="button"
|
||||
class="sheet-row"
|
||||
@click="navigate(item.to)"
|
||||
>
|
||||
<span class="row-icon" v-html="navIconSvg(item.icon, 20)"></span>
|
||||
<span class="row-label">{{ item.label }}</span>
|
||||
<span class="row-chevron" aria-hidden="true" v-html="navIconSvg('chevronRight', 18)"></span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Notifications -->
|
||||
<button type="button" class="sheet-row standalone" @click="emit('notifications')">
|
||||
<span class="row-icon" v-html="navIconSvg('bell', 20)"></span>
|
||||
<span class="row-label">通知</span>
|
||||
<span v-if="unreadCount" class="row-badge">{{ badgeText }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Install as app (hidden once installed) -->
|
||||
<div class="install-slot">
|
||||
<InstallPrompt />
|
||||
</div>
|
||||
|
||||
<!-- Logout -->
|
||||
<button type="button" class="logout-row" @click="emit('logout')">
|
||||
<span class="row-icon" v-html="navIconSvg('logout', 19)"></span>
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.more-sheet-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-sheet);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.more-scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--scrim);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.more-sheet {
|
||||
position: relative;
|
||||
background: var(--bg2);
|
||||
border-top: 1px solid var(--o07);
|
||||
border-top-left-radius: 24px;
|
||||
border-top-right-radius: 24px;
|
||||
padding: 12px 18px calc(var(--space-lg) + var(--safe-bottom));
|
||||
box-shadow: var(--shadow-lg);
|
||||
max-height: calc(100dvh - 60px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
width: 38px;
|
||||
height: 5px;
|
||||
border-radius: 3px;
|
||||
background: var(--o10);
|
||||
margin: 0 auto 18px;
|
||||
}
|
||||
|
||||
.availability-card {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
background: var(--success-color-soft);
|
||||
border: 1px solid color-mix(in srgb, var(--success-color) 35%, transparent);
|
||||
margin-bottom: 14px;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.availability-card.offline {
|
||||
background: var(--o04);
|
||||
border-color: var(--o10);
|
||||
}
|
||||
|
||||
.availability-card:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.availability-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.availability-dot.online {
|
||||
background: var(--success-color);
|
||||
box-shadow: 0 0 0 4px var(--success-color-soft);
|
||||
}
|
||||
|
||||
.availability-text {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.availability-title {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.availability-sub {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.toggle-track {
|
||||
width: 48px;
|
||||
height: 28px;
|
||||
border-radius: 16px;
|
||||
background: var(--toggle-track-off);
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.toggle-track.on {
|
||||
background: var(--success-color);
|
||||
}
|
||||
|
||||
.toggle-knob {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: var(--toggle-knob);
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.toggle-track.on .toggle-knob {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
/* Typography comes from the shared .nav-section-heading (components.css) */
|
||||
.sheet-section {
|
||||
padding: 0 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sheet-card {
|
||||
background: var(--o04);
|
||||
border: 1px solid var(--o07);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.sheet-card .sheet-row + .sheet-row {
|
||||
border-top: 1px solid var(--o06);
|
||||
}
|
||||
|
||||
.sheet-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
padding: 14px 16px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 15px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background-color var(--transition-fast);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.sheet-row:hover {
|
||||
background: var(--o05);
|
||||
}
|
||||
|
||||
.sheet-row.standalone {
|
||||
background: var(--o04);
|
||||
border: 1px solid var(--o07);
|
||||
border-radius: 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.row-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
color: var(--c-purple);
|
||||
}
|
||||
|
||||
.row-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.row-value {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.row-chevron {
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.row-badge {
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
padding: 0 7px;
|
||||
border-radius: 11px;
|
||||
background: var(--c-coral);
|
||||
color: var(--on-light);
|
||||
font-size: 12px;
|
||||
font-weight: var(--font-weight-bold);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.install-slot {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.install-slot:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.logout-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
padding: 13px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--c-coral);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.logout-row .row-icon {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Slide-up transition */
|
||||
.more-sheet-enter-active,
|
||||
.more-sheet-leave-active {
|
||||
transition: opacity var(--transition-normal);
|
||||
}
|
||||
|
||||
.more-sheet-enter-active .more-sheet,
|
||||
.more-sheet-leave-active .more-sheet {
|
||||
transition: transform var(--transition-normal);
|
||||
}
|
||||
|
||||
.more-sheet-enter-from,
|
||||
.more-sheet-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.more-sheet-enter-from .more-sheet,
|
||||
.more-sheet-leave-to .more-sheet {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
isCollapsed: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'toggle'): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button class="toggle-btn" @click="$emit('toggle')">
|
||||
<span class="toggle-icon">{{ isCollapsed ? '▶' : '◀' }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toggle-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex-shrink: 0;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: 7px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color var(--transition-fast), color var(--transition-fast),
|
||||
border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.toggle-btn:hover {
|
||||
background: var(--o10);
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.toggle-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--ring-focus);
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// Shared inline stroke icon set (stroke="currentColor" so glyphs inherit the
|
||||
// surrounding color — muted by default, lime when active). One source of truth
|
||||
// for the sidebar, bottom nav, More sheet, and prompts.
|
||||
export const NAV_ICONS: Record<string, string> = {
|
||||
agents: '<rect x="5" y="8" width="14" height="11" rx="3"/><line x1="12" y1="4" x2="12" y2="8"/><circle cx="9" cy="13" r="1" fill="currentColor"/><circle cx="15" cy="13" r="1" fill="currentColor"/>',
|
||||
humans: '<circle cx="9" cy="8" r="3"/><path d="M3.5 19a5.5 5 0 0 1 11 0"/><circle cx="16.5" cy="9" r="2.3"/><path d="M15 19a4.5 4 0 0 1 5.5-3.6"/>',
|
||||
inbox: '<rect x="3" y="5" width="18" height="14" rx="3"/><path d="M3 13h5l1.5 2.5h4L19 13h2"/>',
|
||||
people: '<circle cx="9" cy="8" r="2.6"/><path d="M4 18a5 4.5 0 0 1 10 0"/><path d="M15.5 6.2a2.6 2.6 0 0 1 0 4.6"/><path d="M16 13.6A5 4.5 0 0 1 20 18"/>',
|
||||
analytics: '<line x1="5" y1="17" x2="5" y2="13"/><line x1="10" y1="17" x2="10" y2="9"/><line x1="15" y1="17" x2="15" y2="6"/><line x1="20" y1="17" x2="20" y2="11"/>',
|
||||
knowledge: '<path d="M4 5.5A2 2 0 0 1 6 4h5v16H6a2 2 0 0 0-2 1.5z"/><path d="M20 5.5A2 2 0 0 0 18 4h-5v16h5a2 2 0 0 1 2 1.5z"/>',
|
||||
faq: '<circle cx="12" cy="12" r="9"/><path d="M9.3 9.3a2.7 2.7 0 0 1 5.2 1c0 1.8-2.5 2-2.5 3.3"/><circle cx="12" cy="17" r=".6" fill="currentColor"/>',
|
||||
tickets: '<path d="M4 9a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2 2 2 0 0 0 0 4 2 2 0 0 1-2 2H6a2 2 0 0 1-2-2 2 2 0 0 0 0-4z"/><line x1="14" y1="8" x2="14" y2="16" stroke-dasharray="2 2.4"/>',
|
||||
ticketing: '<circle cx="12" cy="12" r="3"/><path d="M12 4v2M12 18v2M4 12h2M18 12h2M6 6l1.5 1.5M16.5 16.5 18 18M18 6l-1.5 1.5M7.5 16.5 6 18"/>',
|
||||
org: '<rect x="4" y="4" width="14" height="16" rx="2"/><line x1="20" y1="20" x2="20" y2="11"/><line x1="18" y1="11" x2="22" y2="11"/><circle cx="8" cy="9" r=".6" fill="currentColor"/><circle cx="12" cy="9" r=".6" fill="currentColor"/><circle cx="8" cy="13" r=".6" fill="currentColor"/><circle cx="12" cy="13" r=".6" fill="currentColor"/>',
|
||||
subscription: '<rect x="3" y="6" width="18" height="12" rx="2.5"/><line x1="3" y1="10" x2="21" y2="10"/><line x1="7" y1="14" x2="11" y2="14"/>',
|
||||
integrations: '<line x1="12" y1="12" x2="6" y2="6"/><line x1="12" y1="12" x2="18" y2="6"/><line x1="12" y1="12" x2="12" y2="19"/><circle cx="12" cy="12" r="2.2" fill="currentColor"/><circle cx="6" cy="6" r="2"/><circle cx="18" cy="6" r="2"/><circle cx="12" cy="19" r="2"/>',
|
||||
widgets: '<rect x="4" y="4" width="7" height="7" rx="2"/><rect x="13" y="4" width="7" height="7" rx="2"/><rect x="4" y="13" width="7" height="7" rx="2"/><circle cx="16.5" cy="16.5" r="3.5"/>',
|
||||
aiconfig: '<line x1="4" y1="8" x2="20" y2="8"/><line x1="4" y1="16" x2="20" y2="16"/><circle cx="9" cy="8" r="2.4"/><circle cx="15" cy="16" r="2.4"/>',
|
||||
usersettings: '<circle cx="12" cy="8" r="3.4"/><path d="M5.5 19a6.5 5.5 0 0 1 13 0"/>',
|
||||
more: '<circle cx="5" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="19" cy="12" r="1.4" fill="currentColor" stroke="none"/>',
|
||||
bell: '<path d="M18 8a6 6 0 1 0-12 0c0 7-3 9-3 9h18s-3-2-3-9z"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/>',
|
||||
logout: '<path d="M15 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2v-2"/><path d="M10 12h11m0 0-3-3m3 3-3 3"/>',
|
||||
chevronRight: '<path d="M9 6l6 6-6 6"/>',
|
||||
moon: '<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>',
|
||||
sun: '<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>',
|
||||
monitor: '<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>',
|
||||
}
|
||||
|
||||
// Icons are static — cache built strings so hot paths (bottom nav re-renders
|
||||
// on every route change) don't rebuild them.
|
||||
const svgCache = new Map<string, string>()
|
||||
|
||||
export const navIconSvg = (name?: string, size = 19) => {
|
||||
const key = `${name}:${size}`
|
||||
let svg = svgCache.get(key)
|
||||
if (!svg) {
|
||||
svg = `<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="${name === 'analytics' ? 2 : 1.7}" stroke-linecap="round" stroke-linejoin="round">${name ? (NAV_ICONS[name] || '') : ''}</svg>`
|
||||
svgCache.set(key, svg)
|
||||
}
|
||||
return svg
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { permissionChecks } from '@/utils/permissions'
|
||||
// Visibility comes from the same map the router enforces, so a link can never
|
||||
// appear for a page the guard will refuse (People used to do exactly that).
|
||||
import { canAccessPath } from '@/router/routePermissions'
|
||||
import { useEnterpriseFeatures } from '@/composables/useEnterpriseFeatures'
|
||||
|
||||
// Re-exported so nav consumers keep a single import site
|
||||
export { NAV_ICONS, navIconSvg } from './navIcons'
|
||||
|
||||
export interface NavItem {
|
||||
to?: string
|
||||
icon?: string
|
||||
label?: string
|
||||
section?: string
|
||||
show?: boolean
|
||||
}
|
||||
|
||||
export interface NavGroup {
|
||||
section: string
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
// Shared unread-badge cap (bottom nav, More sheet, header bell)
|
||||
export const formatBadgeCount = (count?: number) =>
|
||||
count && count > 99 ? '99+' : String(count || '')
|
||||
|
||||
// Bottom-nav primary slots, in display order (remaining links go to the More sheet)
|
||||
export const PRIMARY_NAV_PATHS = ['/conversations', '/people', '/ai-agents', '/analytics']
|
||||
|
||||
export function useNavItems() {
|
||||
const { hasEnterpriseModule } = useEnterpriseFeatures()
|
||||
|
||||
// Section membership is explicit rather than inferred from array position:
|
||||
// a header can be permission-hidden while one of its items is not (User
|
||||
// Settings is always visible), which silently orphaned items into the
|
||||
// preceding section.
|
||||
const navGroups = computed<NavGroup[]>(() =>
|
||||
[
|
||||
{
|
||||
section: '主菜单',
|
||||
items: [
|
||||
{
|
||||
to: '/ai-agents',
|
||||
icon: 'agents',
|
||||
label: 'AI 客服',
|
||||
show: canAccessPath('/ai-agents'),
|
||||
},
|
||||
{
|
||||
to: '/human-agents',
|
||||
icon: 'humans',
|
||||
label: '人工客服',
|
||||
show: canAccessPath('/human-agents'),
|
||||
},
|
||||
{
|
||||
to: '/conversations',
|
||||
icon: 'inbox',
|
||||
label: '会话',
|
||||
show: canAccessPath('/conversations'),
|
||||
},
|
||||
{
|
||||
to: '/tickets',
|
||||
icon: 'tickets',
|
||||
label: '工单',
|
||||
show: canAccessPath('/tickets'),
|
||||
},
|
||||
{
|
||||
to: '/people',
|
||||
icon: 'people',
|
||||
label: '联系人',
|
||||
show: canAccessPath('/people'),
|
||||
},
|
||||
{
|
||||
to: '/knowledge',
|
||||
icon: 'knowledge',
|
||||
label: '知识库',
|
||||
show: canAccessPath('/knowledge'),
|
||||
},
|
||||
{
|
||||
to: '/faq',
|
||||
icon: 'faq',
|
||||
label: '帮助中心',
|
||||
show: canAccessPath('/faq'),
|
||||
},
|
||||
{
|
||||
to: '/analytics',
|
||||
icon: 'analytics',
|
||||
label: '数据分析',
|
||||
show: canAccessPath('/analytics'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
section: '设置',
|
||||
items: [
|
||||
{
|
||||
to: '/settings/organization',
|
||||
icon: 'org',
|
||||
label: '组织',
|
||||
show: canAccessPath('/settings/organization'),
|
||||
},
|
||||
{
|
||||
to: '/settings/subscription',
|
||||
icon: 'subscription',
|
||||
label: '订阅',
|
||||
show: hasEnterpriseModule && permissionChecks.canViewSubscription(),
|
||||
},
|
||||
{
|
||||
to: '/settings/ticketing',
|
||||
icon: 'ticketing',
|
||||
label: '工单设置',
|
||||
show: canAccessPath('/settings/ticketing'),
|
||||
},
|
||||
{
|
||||
to: '/settings/integrations',
|
||||
icon: 'integrations',
|
||||
label: '集成',
|
||||
show: canAccessPath('/settings/integrations'),
|
||||
},
|
||||
{
|
||||
to: '/settings/app-integrations',
|
||||
icon: 'integrations',
|
||||
label: '应用集成',
|
||||
show: hasEnterpriseModule && canAccessPath('/settings/app-integrations'),
|
||||
},
|
||||
{
|
||||
to: '/settings/widget-apps',
|
||||
icon: 'widgets',
|
||||
label: '网站组件',
|
||||
show: canAccessPath('/settings/widget-apps'),
|
||||
},
|
||||
{
|
||||
to: '/settings/ai-config',
|
||||
icon: 'aiconfig',
|
||||
label: 'AI 配置',
|
||||
show: canAccessPath('/settings/ai-config'),
|
||||
},
|
||||
{
|
||||
to: '/settings/user',
|
||||
icon: 'usersettings',
|
||||
label: '个人设置',
|
||||
show: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
.map((group) => ({ ...group, items: group.items.filter((item) => item.show !== false) }))
|
||||
// A section is visible when it has something to show — no separate
|
||||
// permission flag to drift from its items
|
||||
.filter((group) => group.items.length > 0),
|
||||
)
|
||||
|
||||
// Flat list (heading followed by its links) for the desktop sidebar
|
||||
const navItems = computed<NavItem[]>(() =>
|
||||
navGroups.value.flatMap((group) => [{ section: group.section }, ...group.items]),
|
||||
)
|
||||
|
||||
// Bottom-nav slots in design order
|
||||
const primaryNavItems = computed<NavItem[]>(() =>
|
||||
PRIMARY_NAV_PATHS.map((path) => navItems.value.find((item) => item.to === path)).filter(
|
||||
(item): item is NavItem => !!item,
|
||||
),
|
||||
)
|
||||
|
||||
// Everything the bottom nav doesn't carry, still grouped like the sidebar
|
||||
const moreNavGroups = computed<NavGroup[]>(() =>
|
||||
navGroups.value
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => item.to && !PRIMARY_NAV_PATHS.includes(item.to)),
|
||||
}))
|
||||
.filter((group) => group.items.length > 0),
|
||||
)
|
||||
|
||||
// Derived from the groups so there is one definition of "overflow link"
|
||||
const moreNavItems = computed<NavItem[]>(() =>
|
||||
moreNavGroups.value.flatMap((group) => group.items),
|
||||
)
|
||||
|
||||
return { navGroups, navItems, primaryNavItems, moreNavItems, moreNavGroups }
|
||||
}
|
||||
@@ -0,0 +1,869 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useOrganizationSettings } from '@/composables/useOrganizationSettings'
|
||||
// @ts-ignore
|
||||
import { timezoneOptions } from '@/utils/timezones'
|
||||
|
||||
const {
|
||||
formData,
|
||||
loading,
|
||||
message,
|
||||
error,
|
||||
stats,
|
||||
hasChanges,
|
||||
days,
|
||||
timeOptions,
|
||||
loadOrganizationData,
|
||||
updateOrganization
|
||||
} = useOrganizationSettings()
|
||||
|
||||
const timezones = ref(timezoneOptions())
|
||||
|
||||
// Danger zone — no destructive API yet; route the user to support.
|
||||
const onTransferOwnership = () => {
|
||||
window.alert('如需转移所有权,请联系 support@chattermate.chat。')
|
||||
}
|
||||
const onDeleteOrganization = () => {
|
||||
if (window.confirm('永久删除该组织及其全部数据?此操作无法撤销。')) {
|
||||
window.alert('删除组织需由支持团队完成 — 请联系 support@chattermate.chat。')
|
||||
}
|
||||
}
|
||||
|
||||
// Business-hours presets — pure client-side mutation of formData
|
||||
const applyPreset = (preset: 'weekday' | 'everyday' | 'always' | 'clear') => {
|
||||
for (const day of days) {
|
||||
const bh = formData.value.business_hours[day.key]
|
||||
const isWeekend = day.key === 'saturday' || day.key === 'sunday'
|
||||
switch (preset) {
|
||||
case 'weekday':
|
||||
bh.enabled = !isWeekend
|
||||
bh.start = '09:00'
|
||||
bh.end = '17:00'
|
||||
break
|
||||
case 'everyday':
|
||||
bh.enabled = true
|
||||
bh.start = '09:00'
|
||||
bh.end = '18:00'
|
||||
break
|
||||
case 'always':
|
||||
bh.enabled = true
|
||||
bh.start = '00:00'
|
||||
bh.end = '23:45'
|
||||
break
|
||||
case 'clear':
|
||||
bh.enabled = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const discardChanges = () => {
|
||||
// Reload from server-backed original state
|
||||
loadOrganizationData()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadOrganizationData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="org-settings">
|
||||
<div v-if="loading && !formData.name" class="loading-container">
|
||||
<div class="loader"></div>
|
||||
</div>
|
||||
|
||||
<div v-else class="org-page">
|
||||
<!-- Page header -->
|
||||
<header class="org-header">
|
||||
<h1 class="org-title">组织</h1>
|
||||
<p class="org-subtitle">你的工作区资料、营业时间与团队概览。</p>
|
||||
</header>
|
||||
|
||||
<!-- Stats strip -->
|
||||
<div v-if="stats" class="stats-strip">
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">成员</span>
|
||||
<span class="stat-value">{{ stats.members_total ?? stats.total_users ?? 0 }}</span>
|
||||
<span class="stat-sub members">{{ stats.members_admins ?? 0 }} 名管理员 · {{ stats.members_agents ?? 0 }} 名客服</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">当前在线</span>
|
||||
<span class="stat-value">{{ stats.active_now ?? 0 }}</span>
|
||||
<span class="stat-sub active">今日活跃</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">AI 智能体</span>
|
||||
<span class="stat-value">{{ stats.agents_total ?? 0 }}</span>
|
||||
<span class="stat-sub agents">{{ stats.agents_live ?? 0 }} 个已上线 · {{ stats.agents_draft ?? 0 }} 个草稿</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-label">会话 · 近 30 天</span>
|
||||
<span class="stat-value">{{ (stats.conversations_30d ?? 0).toLocaleString() }}</span>
|
||||
<span class="stat-sub conversations">
|
||||
{{ (stats.conversations_change_pct ?? 0) >= 0 ? '▲' : '▼' }}
|
||||
{{ Math.abs(stats.conversations_change_pct ?? 0) }}% 较上期
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="updateOrganization">
|
||||
<!-- PROFILE -->
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h3 class="card-title">资料</h3>
|
||||
<p class="card-desc">你的工作区在团队与客户面前展示的样子。</p>
|
||||
</div>
|
||||
|
||||
<div class="field-grid">
|
||||
<div class="field">
|
||||
<label>组织名称</label>
|
||||
<input
|
||||
type="text"
|
||||
v-model="formData.name"
|
||||
placeholder="组织名称"
|
||||
>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>主域名</label>
|
||||
<input
|
||||
type="text"
|
||||
class="mono"
|
||||
v-model="formData.domain"
|
||||
placeholder="example.com"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>时区</label>
|
||||
<select class="select" v-model="formData.timezone">
|
||||
<option
|
||||
v-for="tz in timezones"
|
||||
:key="tz.value"
|
||||
:value="tz.value"
|
||||
>
|
||||
{{ tz.label }}
|
||||
</option>
|
||||
</select>
|
||||
<p class="field-hint">用于工作区内的营业时间、报表与时间戳。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- BUSINESS HOURS -->
|
||||
<section class="card">
|
||||
<div class="card-head-row">
|
||||
<div>
|
||||
<h3 class="card-title">营业时间</h3>
|
||||
<p class="card-desc">人工团队的服务时段。非营业时间由 AI 处理所有会话。</p>
|
||||
</div>
|
||||
<div class="presets">
|
||||
<button type="button" class="preset" @click="applyPreset('weekday')">工作日 9–17</button>
|
||||
<button type="button" class="preset" @click="applyPreset('everyday')">每天 9–18</button>
|
||||
<button type="button" class="preset" @click="applyPreset('always')">24/7</button>
|
||||
<button type="button" class="preset preset-ghost" @click="applyPreset('clear')">全部清除</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hours-list">
|
||||
<div v-for="day in days" :key="day.key" class="day-row">
|
||||
<button
|
||||
type="button"
|
||||
class="toggle"
|
||||
:class="{ on: formData.business_hours[day.key].enabled }"
|
||||
role="switch"
|
||||
:aria-checked="formData.business_hours[day.key].enabled"
|
||||
@click="formData.business_hours[day.key].enabled = !formData.business_hours[day.key].enabled"
|
||||
>
|
||||
<span class="knob"></span>
|
||||
</button>
|
||||
<span
|
||||
class="day-label"
|
||||
:class="{ off: !formData.business_hours[day.key].enabled }"
|
||||
>{{ day.label }}</span>
|
||||
<select
|
||||
class="time-select"
|
||||
v-model="formData.business_hours[day.key].start"
|
||||
:disabled="!formData.business_hours[day.key].enabled"
|
||||
>
|
||||
<option v-for="time in timeOptions" :key="time" :value="time">{{ time }}</option>
|
||||
</select>
|
||||
<span class="time-sep">至</span>
|
||||
<select
|
||||
class="time-select"
|
||||
v-model="formData.business_hours[day.key].end"
|
||||
:disabled="!formData.business_hours[day.key].enabled"
|
||||
>
|
||||
<option v-for="time in timeOptions" :key="time" :value="time">{{ time }}</option>
|
||||
</select>
|
||||
<span
|
||||
class="day-state"
|
||||
:class="formData.business_hours[day.key].enabled ? 'open' : 'closed'"
|
||||
>{{ formData.business_hours[day.key].enabled ? '开放' : '关闭' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- DANGER ZONE -->
|
||||
<section class="card danger-card">
|
||||
<h3 class="danger-title">危险操作</h3>
|
||||
<div class="danger-row">
|
||||
<div>
|
||||
<div class="danger-row-title">转移所有权</div>
|
||||
<div class="danger-row-desc">将此工作区交给其他管理员。</div>
|
||||
</div>
|
||||
<button type="button" class="danger-btn" @click="onTransferOwnership">转移</button>
|
||||
</div>
|
||||
<div class="danger-row last">
|
||||
<div>
|
||||
<div class="danger-row-title">删除组织</div>
|
||||
<div class="danger-row-desc">永久删除此工作区及其全部数据。</div>
|
||||
</div>
|
||||
<button type="button" class="danger-btn delete" @click="onDeleteOrganization">删除</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p v-if="message" class="success-message">{{ message }}</p>
|
||||
<p v-if="error" class="error-message">{{ error }}</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Sticky save bar -->
|
||||
<Transition name="save-bar">
|
||||
<div v-if="hasChanges" class="save-bar">
|
||||
<span class="save-bar-text">有未保存的更改</span>
|
||||
<div class="save-bar-actions">
|
||||
<button type="button" class="btn-discard" @click="discardChanges" :disabled="loading">放弃</button>
|
||||
<button type="button" class="btn-save" @click="updateOrganization" :disabled="loading">
|
||||
{{ loading ? '保存中…' : '保存更改' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.org-settings {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.org-page {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 4px 0 24px;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.loader {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 5px solid var(--primary-soft);
|
||||
border-bottom-color: var(--accent-ink);
|
||||
border-radius: 50%;
|
||||
animation: rotation 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotation {
|
||||
0% { transform: rotate(0deg) }
|
||||
100% { transform: rotate(360deg) }
|
||||
}
|
||||
|
||||
/* Page header */
|
||||
.org-header {
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
|
||||
.org-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 30px;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text);
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.org-subtitle {
|
||||
font-size: 15px;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Stats strip */
|
||||
.stats-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 16px;
|
||||
padding: 18px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-family: var(--font-display);
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
letter-spacing: -0.02em;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
|
||||
.stat-sub {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.stat-sub.members { color: var(--c-online); }
|
||||
.stat-sub.active { color: var(--accent-ink); }
|
||||
.stat-sub.agents { color: var(--c-pro); }
|
||||
.stat-sub.conversations { color: var(--c-coral); }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.stats-strip {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.stats-strip {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Section cards */
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 18px;
|
||||
padding: 26px;
|
||||
margin: 0 0 18px;
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Danger zone */
|
||||
.danger-card {
|
||||
border-color: var(--coral-border);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.danger-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
margin: 0 0 16px;
|
||||
color: var(--c-coral);
|
||||
}
|
||||
|
||||
.danger-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--o06);
|
||||
}
|
||||
|
||||
.danger-row.last {
|
||||
padding: 14px 0 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.danger-row-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.danger-row-desc {
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.danger-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 9px 16px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--o14);
|
||||
border-radius: 10px;
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.danger-btn:hover {
|
||||
background: var(--o06);
|
||||
}
|
||||
|
||||
.danger-btn.delete {
|
||||
background: var(--coral-bg);
|
||||
border-color: var(--coral-border);
|
||||
color: var(--c-coral);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.danger-btn.delete:hover {
|
||||
background: color-mix(in srgb, var(--c-coral) 18%, transparent);
|
||||
}
|
||||
|
||||
.card-head {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.card-head-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 17px;
|
||||
color: var(--text);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 13.5px;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Logo row */
|
||||
.logo-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding-bottom: 22px;
|
||||
border-bottom: 1px solid var(--o07);
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.org-logo {
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 16px;
|
||||
background: var(--grad-lime-teal);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 26px;
|
||||
color: var(--on-accent-solid);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.org-logo.has-image {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o10);
|
||||
}
|
||||
|
||||
.org-logo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.logo-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.logo-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hidden-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.logo-btn {
|
||||
padding: 9px 18px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o14);
|
||||
border-radius: var(--radius-chip);
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.logo-btn:hover:not(:disabled) {
|
||||
background: var(--o10);
|
||||
}
|
||||
|
||||
.logo-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.logo-meta-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.logo-meta-sub {
|
||||
font-size: 12.5px;
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
/* Fields */
|
||||
.field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 18px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text3);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.select {
|
||||
width: 100%;
|
||||
padding: 13px 15px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: var(--radius-input);
|
||||
color: var(--text);
|
||||
font-size: 14.5px;
|
||||
font-family: var(--font-sans);
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.field input.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.field input::placeholder {
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.field input:focus,
|
||||
.select:focus {
|
||||
border-color: var(--accent-ink);
|
||||
box-shadow: var(--ring-focus);
|
||||
}
|
||||
|
||||
.select {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
padding-right: 38px;
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%239CA3B0' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 13px center;
|
||||
background-size: 16px;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
font-size: 12.5px;
|
||||
color: var(--muted2);
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
/* Business hours — presets */
|
||||
.presets {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.preset {
|
||||
padding: 8px 13px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 9px;
|
||||
color: var(--text3);
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.preset:hover {
|
||||
background: var(--o10);
|
||||
}
|
||||
|
||||
.preset-ghost {
|
||||
background: transparent;
|
||||
border-color: var(--o10);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.preset-ghost:hover {
|
||||
background: transparent;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
/* Day rows */
|
||||
.hours-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.day-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 11px 0;
|
||||
border-bottom: 1px solid var(--o05);
|
||||
}
|
||||
|
||||
.day-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.day-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
min-width: 92px;
|
||||
}
|
||||
|
||||
.day-label.off {
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
/* Toggle */
|
||||
.toggle {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: var(--toggle-track-off);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.toggle.on {
|
||||
background: var(--toggle-on-accent);
|
||||
}
|
||||
|
||||
.knob {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--toggle-knob);
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.toggle.on .knob {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
/* Time selects */
|
||||
.time-select {
|
||||
padding: 8px 10px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text);
|
||||
font-size: 13.5px;
|
||||
font-family: var(--font-mono);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.time-select:focus {
|
||||
border-color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.time-select:disabled {
|
||||
background: var(--o03);
|
||||
color: var(--muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.time-sep {
|
||||
color: var(--muted2);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.day-state {
|
||||
margin-left: auto;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.day-state.open {
|
||||
color: var(--c-teal);
|
||||
}
|
||||
|
||||
.day-state.closed {
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
/* Messages */
|
||||
.success-message {
|
||||
color: var(--c-teal);
|
||||
font-size: 13.5px;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--c-coral);
|
||||
font-size: 13.5px;
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
/* Sticky save bar */
|
||||
.save-bar {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 26px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 12px 14px 12px 22px;
|
||||
background: color-mix(in srgb, var(--surface) 96%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.save-bar-text {
|
||||
font-size: 13.5px;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.save-bar-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-discard {
|
||||
padding: 10px 16px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--o14);
|
||||
border-radius: 10px;
|
||||
color: var(--text3);
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-discard:hover:not(:disabled) {
|
||||
background: var(--o06);
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
padding: 10px 20px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
transition: filter var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-save:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.btn-save:disabled,
|
||||
.btn-discard:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.save-bar-enter-active,
|
||||
.save-bar-leave-active {
|
||||
transition: opacity var(--transition-fast), transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.save-bar-enter-from,
|
||||
.save-bar-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(12px);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 640px) {
|
||||
.field-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.day-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.day-state {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,409 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { peopleService, type PersonCrmStatus } from '@/services/people'
|
||||
import channelsService, { type ChannelAccount } from '@/services/channels'
|
||||
import NewWhatsAppConversation from '@/components/conversations/NewWhatsAppConversation.vue'
|
||||
import type { PersonDetail } from '@/types/people'
|
||||
import { permissionChecks } from '@/utils/permissions'
|
||||
|
||||
const PROVIDER_LABELS: Record<string, string> = { hubspot: 'HubSpot', pipedrive: 'Pipedrive' }
|
||||
const providerLabel = (p: string) => PROVIDER_LABELS[p] || p
|
||||
|
||||
const props = defineProps<{ customerId: string }>()
|
||||
const emit = defineEmits<{ (e: 'close'): void; (e: 'updated', stage?: string): void }>()
|
||||
|
||||
const person = ref<PersonDetail | null>(null)
|
||||
const loading = ref(true)
|
||||
const marking = ref(false)
|
||||
|
||||
// Inline contact edit — the identification tool: adding a phone/name is what
|
||||
// turns an anonymous session into a person (and unlocks the actions below).
|
||||
const editing = ref(false)
|
||||
const saving = ref(false)
|
||||
const editName = ref('')
|
||||
const editPhone = ref('')
|
||||
|
||||
function startEdit() {
|
||||
editName.value = person.value?.name || ''
|
||||
editPhone.value = person.value?.phone || ''
|
||||
editing.value = true
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
saving.value = true
|
||||
try {
|
||||
person.value = await peopleService.updatePerson(props.customerId, {
|
||||
full_name: editName.value.trim() || undefined,
|
||||
// "" deliberately passes through: it clears a wrong number.
|
||||
phone: editPhone.value.trim(),
|
||||
})
|
||||
editing.value = false
|
||||
emit('updated')
|
||||
} catch (error: any) {
|
||||
toast.error('保存失败', {
|
||||
description: error?.response?.data?.detail || '请重试',
|
||||
})
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const attrEntries = computed(() => Object.entries(person.value?.captured_attributes || {}))
|
||||
|
||||
// CRM sync state for this person
|
||||
const crm = ref<PersonCrmStatus | null>(null)
|
||||
const syncing = ref(false)
|
||||
const crmConnected = computed(() => (crm.value?.connected_providers.length ?? 0) > 0)
|
||||
const crmSynced = computed(() => crm.value?.synced || [])
|
||||
const syncedSummary = computed(() =>
|
||||
crmSynced.value.map(s => providerLabel(s.provider)).join(', '))
|
||||
const connectedSummary = computed(() =>
|
||||
(crm.value?.connected_providers || []).map(providerLabel).join(', '))
|
||||
// The CRM dedupes on email, so a person needs one before they can sync.
|
||||
// Editing a person, marking them a customer and pushing to CRM all take the
|
||||
// same grant the API requires (PEOPLE_WRITE_PERMISSIONS). These buttons used to
|
||||
// render for everyone who could read the directory and 403 on click.
|
||||
const canEditPeople = permissionChecks.canManagePeople()
|
||||
|
||||
const canSync = computed(() => !!person.value?.email && canEditPeople)
|
||||
|
||||
async function loadCrm() {
|
||||
try { crm.value = await peopleService.getCrmStatus(props.customerId) }
|
||||
catch { /* non-fatal: the widget just shows nothing */ }
|
||||
}
|
||||
|
||||
async function syncNow() {
|
||||
syncing.value = true
|
||||
try {
|
||||
crm.value = await peopleService.syncToCrm(props.customerId)
|
||||
toast.success('已同步至 CRM')
|
||||
} catch (error: any) {
|
||||
toast.error('同步失败', {
|
||||
description: error?.response?.data?.detail || '请重试',
|
||||
})
|
||||
} finally {
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goToIntegrations() {
|
||||
emit('close')
|
||||
router.push('/settings/integrations')
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try { person.value = await peopleService.getPerson(props.customerId) }
|
||||
catch { toast.error('加载联系人失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function markCustomer() {
|
||||
marking.value = true
|
||||
try {
|
||||
person.value = await peopleService.markAsCustomer(props.customerId)
|
||||
toast.success('已标记为客户')
|
||||
emit('updated', 'customer')
|
||||
} catch (error: any) {
|
||||
toast.error('标记为客户失败', {
|
||||
description: error?.response?.data?.detail || undefined,
|
||||
})
|
||||
} finally {
|
||||
marking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// "Message on WhatsApp" — the drawer is the person-centric entry point to the
|
||||
// same modal the Conversations button opens. Disabled-with-reason, matching
|
||||
// the Sync-now pattern above it.
|
||||
const router = useRouter()
|
||||
const whatsappAccounts = ref<ChannelAccount[]>([])
|
||||
const showNewConversation = ref(false)
|
||||
|
||||
async function loadWhatsAppAccounts() {
|
||||
whatsappAccounts.value = await channelsService.listActiveWhatsAppAccounts()
|
||||
}
|
||||
|
||||
const whatsappDisabledReason = computed(() => {
|
||||
if (!whatsappAccounts.value.length) return '请先在 设置 → 集成 中连接 WhatsApp'
|
||||
return ''
|
||||
})
|
||||
|
||||
function onConversationStarted(sessionId: string) {
|
||||
showNewConversation.value = false
|
||||
emit('close')
|
||||
router.push({ path: '/conversations', query: { session: sessionId } })
|
||||
}
|
||||
|
||||
function stageLabel(s?: string) {
|
||||
const labels: Record<string, string> = { visitor: '访客', lead: '线索', customer: '客户' }
|
||||
return s ? (labels[s] || s) : ''
|
||||
}
|
||||
function fmt(d?: string | null) {
|
||||
if (!d) return ''
|
||||
try { return new Date(d).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) } catch { return '' }
|
||||
}
|
||||
|
||||
onMounted(() => { load(); loadWhatsAppAccounts(); loadCrm() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pdd-overlay" @click.self="emit('close')">
|
||||
<aside class="pdd">
|
||||
<div class="pdd-head">
|
||||
<div class="pdd-head-main" v-if="person">
|
||||
<div class="pdd-name">{{ person.name || (person.is_anonymous ? '匿名访客' : (person.email || '—')) }}</div>
|
||||
<div class="pdd-email">
|
||||
{{ person.is_anonymous ? '匿名' : (person.email || '') }}
|
||||
<span v-if="person.phone" class="pdd-phone">{{ person.phone }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="pdd-close" @click="emit('close')">✕</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="pdd-loading">加载中…</div>
|
||||
|
||||
<div v-else-if="person" class="pdd-body">
|
||||
<div class="pdd-stagerow">
|
||||
<span class="pdd-badge" :class="person.lead_stage">{{ stageLabel(person.lead_stage) }}</span>
|
||||
<span v-if="person.qualified" class="pdd-star" title="优质线索">★ 优质线索</span>
|
||||
</div>
|
||||
|
||||
<!-- CRM sync -->
|
||||
<div class="pdd-sync">
|
||||
<!-- No CRM connected for the org -->
|
||||
<template v-if="!crmConnected">
|
||||
<span class="pdd-sync-text">尚未连接 CRM。</span>
|
||||
<button class="pdd-sync-btn" @click="goToIntegrations">连接</button>
|
||||
</template>
|
||||
<!-- Already synced -->
|
||||
<template v-else-if="crmSynced.length">
|
||||
<span class="pdd-sync-text">
|
||||
已同步至 {{ syncedSummary }}
|
||||
<span v-if="crmSynced[0].synced_at" class="pdd-sync-when"> · {{ fmt(crmSynced[0].synced_at) }}</span>
|
||||
<a v-if="crmSynced[0].record_url" :href="crmSynced[0].record_url" target="_blank" rel="noopener" class="pdd-sync-link">在 CRM 中查看 ↗</a>
|
||||
</span>
|
||||
<button class="pdd-sync-btn" :disabled="syncing || !canSync" @click="syncNow">
|
||||
{{ syncing ? '同步中…' : '重新同步' }}
|
||||
</button>
|
||||
</template>
|
||||
<!-- Connected but not yet synced -->
|
||||
<template v-else>
|
||||
<span class="pdd-sync-text">尚未同步至 {{ connectedSummary }}。</span>
|
||||
<button
|
||||
class="pdd-sync-btn primary"
|
||||
:disabled="syncing || !canSync"
|
||||
:title="canSync ? '' : '请先添加邮箱——CRM 按邮箱去重同步'"
|
||||
@click="syncNow"
|
||||
>
|
||||
{{ syncing ? '同步中…' : '立即同步' }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="canEditPeople && person.lead_stage !== 'customer'"
|
||||
class="pdd-mark"
|
||||
:disabled="marking || !person.identified"
|
||||
:title="person.identified ? '' : '请先添加邮箱或电话——此人为匿名访客'"
|
||||
@click="markCustomer"
|
||||
>
|
||||
{{ marking ? '标记中…' : '标记为客户' }}
|
||||
</button>
|
||||
<p v-if="!person.identified" class="pdd-identify-hint">
|
||||
匿名访客——在下方添加姓名或电话即可识别。
|
||||
</p>
|
||||
|
||||
<button
|
||||
class="pdd-whatsapp"
|
||||
:disabled="!!whatsappDisabledReason"
|
||||
:title="whatsappDisabledReason"
|
||||
@click="showNewConversation = true"
|
||||
>
|
||||
<font-awesome-icon :icon="['fab', 'whatsapp']" />
|
||||
通过 WhatsApp 发送消息
|
||||
</button>
|
||||
|
||||
<!-- Contact edit: the one place a wrong phone can be corrected -->
|
||||
<div class="pdd-section-title">
|
||||
联系方式
|
||||
<button
|
||||
v-if="canEditPeople && !editing"
|
||||
type="button"
|
||||
class="pdd-edit-link"
|
||||
@click="startEdit"
|
||||
>编辑</button>
|
||||
</div>
|
||||
<div v-if="editing" class="pdd-edit">
|
||||
<label class="pdd-edit-field">
|
||||
<span>姓名</span>
|
||||
<input v-model="editName" placeholder="张三" autocomplete="off" />
|
||||
</label>
|
||||
<label class="pdd-edit-field">
|
||||
<span>电话</span>
|
||||
<input v-model="editPhone" placeholder="+91 12345 67890" autocomplete="off" />
|
||||
</label>
|
||||
<div class="pdd-edit-actions">
|
||||
<button type="button" class="pdd-edit-btn" @click="editing = false">取消</button>
|
||||
<button type="button" class="pdd-edit-btn primary" :disabled="saving" @click="saveEdit">
|
||||
{{ saving ? '保存中…' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="pdd-attrs">
|
||||
<div class="pdd-attr"><span class="pdd-attr-k">电话</span><span class="pdd-attr-v">{{ person.phone || '—' }}</span></div>
|
||||
</div>
|
||||
|
||||
<!-- AI qualification summary -->
|
||||
<template v-if="person.summary">
|
||||
<div class="pdd-section-title">AI 摘要</div>
|
||||
<div class="pdd-summary">{{ person.summary }}</div>
|
||||
</template>
|
||||
|
||||
<!-- Lifecycle -->
|
||||
<div class="pdd-section-title">生命周期</div>
|
||||
<div class="pdd-timeline">
|
||||
<div v-for="(t, i) in person.timeline" :key="i" class="pdd-tl">
|
||||
<span class="pdd-tl-dot" :class="t.stage"></span>
|
||||
<span class="pdd-tl-label">{{ stageLabel(t.stage) }}</span>
|
||||
<span class="pdd-tl-time">{{ fmt(t.at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Captured attributes -->
|
||||
<div class="pdd-section-title">采集属性</div>
|
||||
<div v-if="attrEntries.length" class="pdd-attrs">
|
||||
<div v-for="[k, v] in attrEntries" :key="k" class="pdd-attr">
|
||||
<span class="pdd-attr-k">{{ k }}</span>
|
||||
<span class="pdd-attr-v">{{ v }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="pdd-none">暂未采集到属性。</div>
|
||||
|
||||
<!-- Conversations -->
|
||||
<div class="pdd-section-title">会话 <span class="pdd-count">{{ person.conversations.length }}</span></div>
|
||||
<div v-if="person.conversations.length" class="pdd-convos">
|
||||
<div v-for="c in person.conversations" :key="c.session_id" class="pdd-convo">
|
||||
<div class="pdd-convo-top">
|
||||
<span class="pdd-agent">{{ c.agent_name || '客服' }}</span>
|
||||
<span class="pdd-status">{{ c.status }}</span>
|
||||
</div>
|
||||
<div class="pdd-snippet">{{ c.last_message || '—' }}</div>
|
||||
<div class="pdd-convo-date">{{ fmt(c.created_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="pdd-none">暂无会话。</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<NewWhatsAppConversation
|
||||
v-if="showNewConversation && person"
|
||||
:accounts="whatsappAccounts"
|
||||
:person="{ id: person.id, name: person.name, phone: person.phone }"
|
||||
@close="showNewConversation = false"
|
||||
@started="onConversationStarted"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pdd-overlay { position: fixed; inset: 0; background: var(--scrim); z-index: var(--z-drawer); display: flex; justify-content: flex-end; }
|
||||
.pdd { width: 440px; max-width: 94vw; height: 100%; background: var(--surface); border-left: 1px solid var(--border-color); display: flex; flex-direction: column; box-shadow: -12px 0 40px rgba(0,0,0,.2); }
|
||||
.pdd-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 20px 22px; border-bottom: 1px solid var(--border-color); }
|
||||
.pdd-name { font-size: 18px; font-weight: 700; }
|
||||
.pdd-email { font-size: 12px; color: var(--muted); margin-top: 3px; }
|
||||
.pdd-close { width: 30px; height: 30px; border-radius: 8px; border: 1px solid var(--border-color); background: transparent; cursor: pointer; flex-shrink: 0; }
|
||||
.pdd-loading { padding: 40px; text-align: center; color: var(--muted); }
|
||||
.pdd-body { flex: 1; overflow-y: auto; padding: 20px 22px; }
|
||||
.pdd-stagerow { display: flex; align-items: center; gap: 10px; margin-bottom: 18px; }
|
||||
.pdd-badge { padding: 4px 12px; border-radius: 999px; font-size: 12.5px; font-weight: 600; }
|
||||
.pdd-badge.visitor { background: rgba(0,0,0,.06); color: var(--muted); }
|
||||
.pdd-badge.lead { background: var(--purple-bg); color: var(--c-purple); }
|
||||
.pdd-badge.customer { background: var(--accent-bg-12); color: var(--primary-color); }
|
||||
.pdd-star { font-size: 12.5px; color: #f59e0b; }
|
||||
.pdd-sync { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 12px 14px; background: var(--o05); border: 1px solid var(--border-color); border-radius: 12px; margin-bottom: 12px; }
|
||||
.pdd-sync-text { font-size: 13px; color: var(--muted); }
|
||||
.pdd-sync-when { color: var(--muted); }
|
||||
.pdd-sync-link { display: block; margin-top: 4px; color: var(--c-info); font-size: 12px; text-decoration: none; }
|
||||
.pdd-sync-link:hover { text-decoration: underline; }
|
||||
.pdd-sync-btn { flex-shrink: 0; padding: 7px 14px; border-radius: 9px; border: 1px solid var(--border-color); background: transparent; color: var(--text); font-size: 13px; cursor: pointer; }
|
||||
.pdd-sync-btn:hover:not(:disabled) { background: var(--o10); }
|
||||
.pdd-sync-btn.primary { background: var(--accent-solid); color: var(--on-accent-solid); border-color: transparent; }
|
||||
.pdd-sync-btn:disabled { opacity: .5; cursor: default; }
|
||||
.pdd-mark { width: 100%; padding: 10px; border-radius: 10px; border: none; background: var(--accent-solid); color: var(--on-accent-solid); font-weight: 600; font-size: 14px; cursor: pointer; margin-bottom: 22px; }
|
||||
.pdd-mark:disabled { opacity: .6; cursor: default; }
|
||||
.pdd-identify-hint { font-size: 12px; color: var(--muted); margin: -14px 0 18px; }
|
||||
.pdd-whatsapp { width: 100%; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 10px; border-radius: 10px; border: 1px solid var(--border-color); background: transparent; color: var(--text); font-weight: 600; font-size: 14px; cursor: pointer; margin-bottom: 22px; }
|
||||
.pdd-whatsapp:disabled { opacity: .55; cursor: default; }
|
||||
.pdd-phone { margin-left: 8px; font-variant-numeric: tabular-nums; }
|
||||
.pdd-edit-link { margin-left: auto; border: none; background: none; color: var(--c-info); font-size: 11px; letter-spacing: normal; text-transform: none; cursor: pointer; padding: 0; }
|
||||
.pdd-edit { background: var(--o05); border: 1px solid var(--border-color); border-radius: 12px; padding: 12px 14px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.pdd-edit-field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
|
||||
.pdd-edit-field input { padding: 8px 10px; border: 1px solid var(--border-color); border-radius: 8px; background: var(--surface); color: var(--text); font-size: 13px; }
|
||||
.pdd-edit-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.pdd-edit-btn { padding: 7px 12px; border-radius: 8px; border: 1px solid var(--border-color); background: transparent; font-size: 12.5px; cursor: pointer; color: var(--text); }
|
||||
.pdd-edit-btn.primary { background: var(--accent-solid); color: var(--on-accent-solid); border-color: transparent; }
|
||||
.pdd-edit-btn:disabled { opacity: .6; cursor: default; }
|
||||
.pdd-section-title { font-size: 10.5px; letter-spacing: .07em; color: var(--muted); margin: 18px 0 12px; display: flex; align-items: center; gap: 8px; }
|
||||
.pdd-summary { background: var(--purple-bg); border: 1px solid var(--purple-border, var(--o12)); border-radius: 12px; padding: 12px 14px; font-size: 13px; line-height: 1.55; color: var(--text2); }
|
||||
.pdd-count { font-weight: 600; }
|
||||
.pdd-timeline { display: flex; flex-direction: column; gap: 12px; }
|
||||
.pdd-tl { display: flex; align-items: center; gap: 10px; font-size: 13px; }
|
||||
.pdd-tl-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--muted); }
|
||||
.pdd-tl-dot.lead { background: var(--c-purple); }
|
||||
.pdd-tl-dot.customer { background: var(--primary-color); }
|
||||
.pdd-tl-label { font-weight: 600; }
|
||||
.pdd-tl-time { color: var(--muted); font-size: 12px; margin-left: auto; }
|
||||
.pdd-attrs { background: var(--o05); border: 1px solid var(--border-color); border-radius: 12px; padding: 4px 14px; }
|
||||
.pdd-attr { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; padding: 10px 0; border-bottom: 1px solid var(--border-color); font-size: 13px; }
|
||||
.pdd-attr:last-child { border-bottom: none; }
|
||||
.pdd-attr-k { color: var(--muted); flex-shrink: 0; }
|
||||
.pdd-attr-v { text-align: right; font-weight: 500; word-break: break-word; }
|
||||
.pdd-none { font-size: 13px; color: var(--muted); padding: 6px 0; }
|
||||
.pdd-convos { display: flex; flex-direction: column; gap: 10px; }
|
||||
.pdd-convo { background: var(--o05); border: 1px solid var(--border-color); border-radius: 12px; padding: 12px 14px; }
|
||||
.pdd-convo-top { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 6px; }
|
||||
.pdd-agent { font-size: 12.5px; font-weight: 600; }
|
||||
.pdd-status { font-size: 11px; color: var(--muted); }
|
||||
.pdd-snippet { font-size: 13px; color: var(--muted); line-height: 1.5; }
|
||||
.pdd-convo-date { font-size: 11px; color: var(--muted); margin-top: 6px; }
|
||||
|
||||
/* Mobile: full-screen sheet rather than a 440px drawer with a dead sliver */
|
||||
@media (max-width: 768px) {
|
||||
.pdd {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border-left: none;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
.pdd-head {
|
||||
padding: calc(16px + var(--safe-top)) 16px 16px;
|
||||
}
|
||||
|
||||
/* 44px minimum touch target (Apple HIG / WCAG 2.5.8) */
|
||||
.pdd-close { width: 44px; height: 44px; }
|
||||
|
||||
.pdd-body {
|
||||
padding: 16px 16px calc(24px + var(--safe-bottom));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,936 @@
|
||||
<!--
|
||||
Copyright 2024-2026 ChatterMate
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, inject, computed, watch } from 'vue'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { userService } from '@/services/user'
|
||||
import { meetsPasswordPolicy, validatePassword, type PasswordStrength } from '@/utils/validators'
|
||||
import PasswordStrengthMeter from '@/components/common/PasswordStrengthMeter.vue'
|
||||
import userAvatar from '@/assets/user.svg'
|
||||
import type { User } from '@/types/user'
|
||||
import { isAbsoluteUrl } from '@/utils/avatars'
|
||||
import { myAvatarUrl } from '@/config/api'
|
||||
import { useNotificationSettings } from '@/composables/useNotificationSettings'
|
||||
import { useNotifications } from '@/composables/useNotifications'
|
||||
import type { NotificationSettings } from '@/services/notification'
|
||||
|
||||
const { user } = useAuth()
|
||||
|
||||
const {
|
||||
settings: notificationSettings,
|
||||
isLoading: notificationsLoading,
|
||||
isSaving: notificationsSaving,
|
||||
save: saveNotificationSetting
|
||||
} = useNotificationSettings()
|
||||
const { hasPermission: hasPushPermission, enableNotifications } = useNotifications()
|
||||
|
||||
// One row per toggle so the markup stays a single loop.
|
||||
const NOTIFICATION_TOGGLES: { key: keyof NotificationSettings; title: string; desc: string }[] = [
|
||||
{
|
||||
key: 'notify_new_chat',
|
||||
title: '新会话',
|
||||
desc: '有新会话开始时,包括 AI 正在处理的会话。'
|
||||
},
|
||||
{
|
||||
key: 'notify_chat_transfer',
|
||||
title: '转接到我所在团队的会话',
|
||||
desc: 'AI 客服将会话转交给你所在的团队时。'
|
||||
},
|
||||
{
|
||||
key: 'notify_chat_assigned',
|
||||
title: '分配给我的会话',
|
||||
desc: '同事将会话重新分配给你时。'
|
||||
}
|
||||
]
|
||||
|
||||
const profilePicFile = ref<File | null>(null)
|
||||
const profilePicPreview = ref<string>('')
|
||||
const formData = ref({
|
||||
full_name: '',
|
||||
email: '',
|
||||
current_password: '',
|
||||
new_password: '',
|
||||
confirm_password: ''
|
||||
})
|
||||
|
||||
// Define the fileInput ref with proper typing
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const loading = ref(false)
|
||||
const message = ref('')
|
||||
const error = ref('')
|
||||
|
||||
const passwordTouched = ref(false)
|
||||
const passwordStrength = ref<PasswordStrength>({
|
||||
score: 0,
|
||||
hasMinLength: false,
|
||||
hasUpperCase: false,
|
||||
hasLowerCase: false,
|
||||
hasNumber: false,
|
||||
hasSpecialChar: false
|
||||
})
|
||||
|
||||
|
||||
const hasChanges = computed(() => {
|
||||
if (!user.value) return false
|
||||
|
||||
const hasProfileChanges =
|
||||
formData.value.full_name !== user.value.full_name ||
|
||||
formData.value.email !== user.value.email
|
||||
|
||||
const hasPasswordChanges =
|
||||
formData.value.new_password ||
|
||||
formData.value.current_password ||
|
||||
formData.value.confirm_password
|
||||
|
||||
return hasProfileChanges || hasPasswordChanges
|
||||
})
|
||||
|
||||
const avatarInitial = computed(() => {
|
||||
const name = formData.value.full_name || user.value?.full_name || user.value?.email || ''
|
||||
return name.trim().charAt(0).toUpperCase() || 'U'
|
||||
})
|
||||
|
||||
const discardChanges = () => {
|
||||
if (user.value) {
|
||||
formData.value.full_name = user.value.full_name
|
||||
formData.value.email = user.value.email
|
||||
}
|
||||
formData.value.current_password = ''
|
||||
formData.value.new_password = ''
|
||||
formData.value.confirm_password = ''
|
||||
passwordTouched.value = false
|
||||
error.value = ''
|
||||
message.value = ''
|
||||
}
|
||||
|
||||
const userAvatarSrc = computed(() => {
|
||||
if (profilePicPreview.value) return profilePicPreview.value
|
||||
if (!user.value?.profile_pic) return userAvatar
|
||||
// Same reasoning as the header avatar: the stored URL was signed at login and
|
||||
// expires within the hour, so ask the API to sign a fresh one instead.
|
||||
return myAvatarUrl(new Date().getTime())
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
// Load current user data
|
||||
if (user.value) {
|
||||
formData.value.full_name = user.value.full_name
|
||||
formData.value.email = user.value.email
|
||||
}
|
||||
})
|
||||
|
||||
const handlePasswordInput = (password: string) => {
|
||||
if (!passwordTouched.value && password.length > 0) {
|
||||
passwordTouched.value = true
|
||||
}
|
||||
passwordStrength.value = validatePassword(password)
|
||||
}
|
||||
|
||||
const handleFileSelect = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
if (!input.files?.length) return
|
||||
|
||||
const file = input.files[0]
|
||||
if (!file.type.startsWith('image/')) {
|
||||
error.value = '请选择图片文件'
|
||||
return
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
error.value = '图片大小需小于 5MB'
|
||||
return
|
||||
}
|
||||
|
||||
// Clear any previous error messages
|
||||
error.value = ''
|
||||
|
||||
// Set the file and create preview
|
||||
profilePicFile.value = file
|
||||
profilePicPreview.value = URL.createObjectURL(file)
|
||||
|
||||
|
||||
}
|
||||
|
||||
const resetFileInput = () => {
|
||||
// Clear preview and file
|
||||
profilePicFile.value = null
|
||||
profilePicPreview.value = ''
|
||||
|
||||
// Reset the file input element to allow selecting the same file again
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const uploadProfilePic = async () => {
|
||||
if (!profilePicFile.value) return
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
message.value = ''
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', profilePicFile.value)
|
||||
|
||||
await userService.uploadProfilePic(formData)
|
||||
message.value = '头像已更新'
|
||||
|
||||
// Update the current user data with new profile pic and force refresh
|
||||
if (user.value) {
|
||||
const updatedUser = userService.getCurrentUser() as User
|
||||
if (updatedUser && updatedUser.profile_pic) {
|
||||
user.value = {
|
||||
...updatedUser,
|
||||
profile_pic: isAbsoluteUrl(updatedUser.profile_pic) ? updatedUser.profile_pic : `${updatedUser.profile_pic}?t=${new Date().getTime()}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the file input
|
||||
resetFileInput()
|
||||
|
||||
|
||||
|
||||
} catch (err: any) {
|
||||
error.value = err.message || '头像上传失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateProfile = async () => {
|
||||
if (!hasChanges.value) {
|
||||
message.value = '没有需要保存的更改'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
message.value = ''
|
||||
|
||||
try {
|
||||
// Create update data only with changed fields
|
||||
const updateData: Record<string, string> = {}
|
||||
|
||||
if (formData.value.full_name !== user.value?.full_name) {
|
||||
updateData.full_name = formData.value.full_name
|
||||
}
|
||||
|
||||
if (formData.value.email !== user.value?.email) {
|
||||
updateData.email = formData.value.email
|
||||
}
|
||||
|
||||
if (formData.value.new_password) {
|
||||
if (formData.value.new_password !== formData.value.confirm_password) {
|
||||
throw new Error('两次输入的新密码不一致')
|
||||
}
|
||||
if (!formData.value.current_password) {
|
||||
throw new Error('设置新密码前请输入当前密码')
|
||||
}
|
||||
if (!meetsPasswordPolicy(passwordStrength.value)) {
|
||||
throw new Error('密码强度不足')
|
||||
}
|
||||
updateData.password = formData.value.new_password
|
||||
updateData.current_password = formData.value.current_password
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
message.value = '没有需要保存的更改'
|
||||
return
|
||||
}
|
||||
|
||||
await userService.updateProfile(updateData)
|
||||
user.value = userService.getCurrentUser()
|
||||
message.value = '个人资料已更新'
|
||||
|
||||
// Clear password fields
|
||||
formData.value.current_password = ''
|
||||
formData.value.new_password = ''
|
||||
formData.value.confirm_password = ''
|
||||
|
||||
} catch (err: any) {
|
||||
error.value = err.message || '个人资料更新失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for changes to profilePicFile
|
||||
watch(profilePicFile, (newFile) => {
|
||||
if (newFile) {
|
||||
// Auto-upload when a file is selected
|
||||
uploadProfilePic()
|
||||
}
|
||||
})
|
||||
|
||||
const handleProfilePicClick = () => {
|
||||
if (fileInput.value) {
|
||||
fileInput.value.click()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<div class="settings-head">
|
||||
<h1>个人设置</h1>
|
||||
<p>管理你的个人资料、密码和通知。</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="updateProfile" class="settings-form">
|
||||
<!-- Profile card -->
|
||||
<div class="settings-card">
|
||||
<h3 class="card-title">个人资料</h3>
|
||||
|
||||
<div class="profile-row">
|
||||
<div class="profile-pic-wrapper" @click="handleProfilePicClick">
|
||||
<img
|
||||
v-if="profilePicPreview || user?.profile_pic"
|
||||
:src="userAvatarSrc"
|
||||
alt="头像"
|
||||
class="profile-pic"
|
||||
/>
|
||||
<span v-else class="avatar-initial">{{ avatarInitial }}</span>
|
||||
<div class="profile-pic-overlay">
|
||||
<font-awesome-icon icon="fa-solid fa-camera" />
|
||||
<span>更换头像</span>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
@change="handleFileSelect"
|
||||
class="file-input"
|
||||
ref="fileInput"
|
||||
>
|
||||
<div class="profile-pic-meta">
|
||||
<div class="profile-pic-title">头像</div>
|
||||
<div class="profile-pic-hint">支持 JPG、PNG 或 GIF,最大 5MB。</div>
|
||||
</div>
|
||||
<div class="profile-pic-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="upload-button"
|
||||
@click="handleProfilePicClick"
|
||||
:disabled="loading"
|
||||
>
|
||||
{{ loading ? '上传中…' : '上传' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-grid">
|
||||
<div class="form-group">
|
||||
<label>姓名</label>
|
||||
<input
|
||||
type="text"
|
||||
v-model="formData.full_name"
|
||||
placeholder="你的姓名"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>邮箱</label>
|
||||
<input
|
||||
type="email"
|
||||
v-model="formData.email"
|
||||
placeholder="你的邮箱"
|
||||
class="input-mono"
|
||||
disabled
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="role-row">
|
||||
<span class="role-label">角色</span>
|
||||
<span class="role-badge">{{ user?.role?.name || '用户' }}</span>
|
||||
<span class="role-note">请联系管理员更改你的角色。</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change password card -->
|
||||
<div class="settings-card">
|
||||
<h3 class="card-title">修改密码</h3>
|
||||
<p class="card-subtitle">至少 8 个字符,并同时包含字母和数字。</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label>当前密码</label>
|
||||
<input
|
||||
type="password"
|
||||
v-model="formData.current_password"
|
||||
placeholder="••••••••"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="field-grid">
|
||||
<div class="form-group">
|
||||
<label>新密码</label>
|
||||
<input
|
||||
type="password"
|
||||
v-model="formData.new_password"
|
||||
@input="handlePasswordInput(formData.new_password)"
|
||||
placeholder="输入新密码"
|
||||
minlength="8"
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>确认新密码</label>
|
||||
<input
|
||||
type="password"
|
||||
v-model="formData.confirm_password"
|
||||
placeholder="再次输入新密码"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PasswordStrengthMeter
|
||||
v-if="passwordTouched && formData.new_password"
|
||||
:strength="passwordStrength"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<!-- Sticky save bar -->
|
||||
<div v-if="hasChanges" class="save-bar">
|
||||
<span class="save-bar-text">有尚未保存的更改</span>
|
||||
<div class="save-bar-actions">
|
||||
<button type="button" class="discard-button" @click="discardChanges">放弃更改</button>
|
||||
<button type="submit" class="submit-button" :disabled="loading">
|
||||
{{ loading ? '保存中…' : '保存更改' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="message" class="save-bar saved-bar">
|
||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"></path></svg>
|
||||
<span class="saved-text">{{ message }}</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Notifications: saved on toggle, so deliberately outside the profile form -->
|
||||
<div class="settings-card notifications-card">
|
||||
<h3 class="card-title">通知</h3>
|
||||
<p class="card-subtitle">选择哪些会话事件会向你发送推送通知。</p>
|
||||
|
||||
<div v-if="!hasPushPermission" class="permission-notice">
|
||||
<font-awesome-icon icon="fa-solid fa-bell-slash" class="permission-icon" />
|
||||
<span class="permission-text">
|
||||
浏览器尚未获得通知权限,下方设置的通知暂时无法送达。
|
||||
</span>
|
||||
<button type="button" class="upload-button" @click="enableNotifications">启用</button>
|
||||
</div>
|
||||
|
||||
<div v-if="notificationsLoading" class="notification-hint">加载偏好设置…</div>
|
||||
|
||||
<template v-else-if="notificationSettings">
|
||||
<div v-for="toggle in NOTIFICATION_TOGGLES" :key="toggle.key" class="notification-row">
|
||||
<div class="notification-meta">
|
||||
<div class="notification-title">{{ toggle.title }}</div>
|
||||
<div class="notification-desc">{{ toggle.desc }}</div>
|
||||
</div>
|
||||
<label class="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="notificationSettings[toggle.key]"
|
||||
:disabled="notificationsSaving"
|
||||
@change="saveNotificationSetting({ [toggle.key]: !notificationSettings[toggle.key] })"
|
||||
>
|
||||
<span class="slider" :class="{ enabled: notificationSettings[toggle.key] }"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="notification-hint">已自动保存。</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-page {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-lg);
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.settings-head {
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
|
||||
.settings-head h1 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 30px;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text);
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.settings-head p {
|
||||
font-size: 15px;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: 18px;
|
||||
padding: 26px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 17px;
|
||||
color: var(--text);
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
font-size: 13.5px;
|
||||
color: var(--muted);
|
||||
margin: -16px 0 20px;
|
||||
}
|
||||
|
||||
/* Notifications card */
|
||||
.notifications-card {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.permission-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: var(--radius-btn);
|
||||
background: var(--bg2);
|
||||
}
|
||||
|
||||
.permission-icon {
|
||||
color: var(--muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.permission-text {
|
||||
flex: 1;
|
||||
font-size: 13.5px;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.notification-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 16px 0;
|
||||
border-top: 1px solid var(--o07);
|
||||
}
|
||||
|
||||
.notification-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notification-title {
|
||||
font-size: 14.5px;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.notification-desc {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.notification-hint {
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--o07);
|
||||
}
|
||||
|
||||
/* Toggle switch (46x26) — matches the agent settings toggles */
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 46px;
|
||||
height: 26px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
inset: 0;
|
||||
background: var(--toggle-track-off);
|
||||
transition: background 0.15s;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.slider.enabled {
|
||||
background: var(--toggle-on-teal);
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
left: 3px;
|
||||
top: 3px;
|
||||
background: var(--toggle-knob);
|
||||
transition: transform 0.15s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
/* Avatar row */
|
||||
.profile-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding-bottom: 22px;
|
||||
border-bottom: 1px solid var(--o07);
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.profile-pic-wrapper {
|
||||
position: relative;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background: var(--grad-purple-teal);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.profile-pic-wrapper:hover .profile-pic-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.avatar-initial {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 26px;
|
||||
color: var(--on-accent-solid);
|
||||
}
|
||||
|
||||
.profile-pic {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.profile-pic-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
font-size: 10px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.profile-pic-overlay svg {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.profile-pic-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-pic-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.profile-pic-hint {
|
||||
font-size: 12.5px;
|
||||
color: var(--muted2);
|
||||
}
|
||||
|
||||
.profile-pic-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.upload-button {
|
||||
padding: 9px 16px;
|
||||
background: var(--o06);
|
||||
border: 1px solid var(--o14);
|
||||
border-radius: 10px;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.upload-button:hover:not(:disabled) {
|
||||
background: var(--o10);
|
||||
}
|
||||
|
||||
.upload-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Inputs */
|
||||
.field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-group + .form-group,
|
||||
.form-group + .field-grid,
|
||||
.field-grid + .form-group {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* Form-groups inside a 2-col grid align at the top — the grid gap handles spacing */
|
||||
.field-grid > .form-group + .form-group {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.field-grid {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text3);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 13px 15px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 11px;
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14.5px;
|
||||
outline: none;
|
||||
transition: all 0.2s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
border-color: var(--accent-ink);
|
||||
box-shadow: var(--ring-focus);
|
||||
}
|
||||
|
||||
.form-group input.input-mono,
|
||||
.form-group input:disabled {
|
||||
background: var(--o03);
|
||||
border: 1px solid var(--o08);
|
||||
color: var(--muted);
|
||||
font-family: var(--font-mono);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Role badge */
|
||||
.role-row {
|
||||
margin-top: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.role-label {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--accent-bg-12);
|
||||
border: 1px solid var(--accent-border);
|
||||
color: var(--accent-ink);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.role-note {
|
||||
font-size: 12px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
/* Sticky save bar */
|
||||
.save-bar {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 26px;
|
||||
transform: translateX(-50%);
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 12px 14px 12px 22px;
|
||||
background: color-mix(in srgb, var(--surface) 96%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.save-bar-text {
|
||||
font-size: 13.5px;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.save-bar-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.discard-button {
|
||||
padding: 10px 16px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--o14);
|
||||
border-radius: 10px;
|
||||
color: var(--text3);
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.discard-button:hover {
|
||||
background: var(--o06);
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
padding: 10px 20px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-sans);
|
||||
transition: filter 0.2s ease;
|
||||
}
|
||||
|
||||
.submit-button:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.submit-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.saved-bar {
|
||||
gap: 10px;
|
||||
padding: 12px 20px;
|
||||
border-color: var(--accent-border);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.saved-text {
|
||||
font-size: 13.5px;
|
||||
color: var(--accent-ink);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--error-color);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.settings-page {
|
||||
padding: var(--space-md);
|
||||
padding-bottom: 90px;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.field-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.save-bar {
|
||||
left: var(--space-md);
|
||||
right: var(--space-md);
|
||||
transform: none;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
Reference in new issue
Block a user