feat(auth): 后台登录、角色权限与会话访问策略

管理员与客服分角色登录,权限由后端判定;前端补齐登录、人员与组织管理页面。
This commit is contained in:
WangShen007 committed 2026-09-24 21:35:00 +08:00
1 parent 9eb8613c05
commit 556b650c7a
123 files changed
+25293

No files matched your search

@@ -0,0 +1,192 @@
<!--
Copyright 2024-2026 ReplyPier
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 ReplyPier
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 ReplyPier
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 ReplyPier
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>
+119
View File
@@ -0,0 +1,119 @@
<!--
Copyright 2024-2026 ReplyPier
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')">&times;</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 ReplyPier
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 ReplyPier
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 ReplyPier
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 ReplyPier
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 ReplyPier
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 ReplyPier
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 ReplyPier
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 ReplyPier
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 ReplyPier
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">ReplyPier</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 ReplyPier
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 ReplyPier
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 ReplyPier
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 ReplyPier
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
}
+197
View File
@@ -0,0 +1,197 @@
/*
Copyright 2024-2026 ReplyPier
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 ReplyPier
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@replypier.site。')
}
const onDeleteOrganization = () => {
if (window.confirm('永久删除该组织及其全部数据?此操作无法撤销。')) {
window.alert('删除组织需由支持团队完成 — 请联系 support@replypier.site。')
}
}
// 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 ReplyPier
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 ReplyPier
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>