feat(auth): 后台登录、角色权限与会话访问策略
管理员与客服分角色登录,权限由后端判定;前端补齐登录、人员与组织管理页面。
This commit is contained in:
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>
|
||||
@@ -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')">×</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>
|
||||
Reference in new issue
Block a user