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>