feat:历史记录分享功能实现
This commit is contained in:
471
frontend/src/components/SharePlanDialog/index.vue
Normal file
471
frontend/src/components/SharePlanDialog/index.vue
Normal file
@@ -0,0 +1,471 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:width="width"
|
||||
:close-on-click-modal="true"
|
||||
:center="true"
|
||||
:show-close="true"
|
||||
top="20vh"
|
||||
title="链接分享"
|
||||
@closed="handleClosed"
|
||||
>
|
||||
<div class="share-content">
|
||||
<!-- 设置区域 -->
|
||||
<div v-if="!loading && !error" class="settings-section">
|
||||
<!-- 有效期设置 -->
|
||||
<div class="setting-row inline">
|
||||
<div class="setting-label">有效期:</div>
|
||||
<div class="option-box">
|
||||
<div
|
||||
v-for="option in expirationOptions"
|
||||
:key="option.value"
|
||||
:class="['option-item', { active: expirationDays === option.value }]"
|
||||
@click="expirationDays = option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提取码设置 -->
|
||||
<div class="setting-row inline">
|
||||
<div class="setting-label">提取码:</div>
|
||||
<div class="code-option-box">
|
||||
<div
|
||||
:class="['option-item', { active: codeType === 'random' }]"
|
||||
@click="handleCodeTypeChange('random')"
|
||||
>
|
||||
随机生成
|
||||
</div>
|
||||
<div
|
||||
:class="['option-item', { active: codeType === 'custom' }]"
|
||||
@click="handleCodeTypeChange('custom')"
|
||||
>
|
||||
自定义
|
||||
</div>
|
||||
</div>
|
||||
<!-- 自定义输入框 -->
|
||||
<div v-if="codeType === 'custom'" class="custom-code-wrapper">
|
||||
<el-input
|
||||
v-model="customCode"
|
||||
placeholder="仅支持字母/数字"
|
||||
class="custom-code-input"
|
||||
maxlength="4"
|
||||
@input="validateCustomCode"
|
||||
>
|
||||
<template #suffix>
|
||||
<span v-if="customCode" class="code-count">{{ customCode.length }}/4</span>
|
||||
</template>
|
||||
</el-input>
|
||||
<span v-if="!customCode" class="code-hint">请输入提取码</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 自动填充提取码选项 -->
|
||||
<div class="setting-row">
|
||||
<el-checkbox v-model="autoFillCode" class="auto-fill-checkbox">
|
||||
分享链接自动填充提取码
|
||||
</el-checkbox>
|
||||
</div>
|
||||
|
||||
<!-- 复制链接按钮 -->
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="generate-btn"
|
||||
:disabled="!canGenerate"
|
||||
:loading="loading"
|
||||
@click="handleCopyLink"
|
||||
>
|
||||
复制链接
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-else-if="error" class="error-state">
|
||||
<div class="error-icon">
|
||||
<SvgIcon icon-class="JingGao" size="48px" color="#f56c6c" />
|
||||
</div>
|
||||
<p class="error-text">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import SvgIcon from '@/components/SvgIcon/index.vue'
|
||||
import websocket from '@/utils/websocket'
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
planId?: string
|
||||
width?: string | number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
planId: '',
|
||||
width: '450px'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
// 有效期选项
|
||||
const expirationOptions = [
|
||||
{ label: '1天', value: 1 },
|
||||
{ label: '7天', value: 7 },
|
||||
{ label: '30天', value: 30 },
|
||||
{ label: '365天', value: 365 },
|
||||
{ label: '永久有效', value: 0 }
|
||||
]
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const dialogVisible = ref(props.modelValue)
|
||||
const expirationDays = ref(7) // 默认7天
|
||||
const codeType = ref<'random' | 'custom'>('random') // 提取码类型
|
||||
const customCode = ref('') // 自定义提取码
|
||||
const generatedCode = ref('') // 生成的提取码
|
||||
const autoFillCode = ref(true) // 分享链接自动填充提取码
|
||||
|
||||
// 是否可以生成链接
|
||||
const canGenerate = computed(() => {
|
||||
if (codeType.value === 'random') {
|
||||
return true // 随机生成总是可以
|
||||
} else {
|
||||
return /^[a-zA-Z0-9]{4}$/.test(customCode.value)
|
||||
}
|
||||
})
|
||||
|
||||
// 生成唯一请求ID
|
||||
let requestIdCounter = 0
|
||||
const generateRequestId = () => `ws_req_${Date.now()}_${++requestIdCounter}`
|
||||
|
||||
// 监听 v-model 变化
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
val => {
|
||||
dialogVisible.value = val
|
||||
if (val && props.planId) {
|
||||
// 打开弹窗时重置状态
|
||||
loading.value = false
|
||||
error.value = ''
|
||||
// 生成随机提取码
|
||||
generateRandomCode()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 监听对话框显示状态变化
|
||||
watch(dialogVisible, val => {
|
||||
emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
// 生成随机提取码(4位字母数字)
|
||||
const generateRandomCode = () => {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||
let code = ''
|
||||
for (let i = 0; i < 4; i++) {
|
||||
code += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
generatedCode.value = code
|
||||
}
|
||||
|
||||
// 处理提取码类型变化
|
||||
const handleCodeTypeChange = (type: 'random' | 'custom') => {
|
||||
codeType.value = type
|
||||
if (type === 'random') {
|
||||
generateRandomCode()
|
||||
}
|
||||
}
|
||||
|
||||
// 验证自定义提取码
|
||||
const validateCustomCode = (value: string) => {
|
||||
// 只允许字母和数字
|
||||
customCode.value = value.replace(/[^a-zA-Z0-9]/g, '').toUpperCase()
|
||||
}
|
||||
|
||||
// 生成并复制链接
|
||||
const handleCopyLink = async () => {
|
||||
if (!props.planId) {
|
||||
error.value = '任务 ID 为空'
|
||||
return
|
||||
}
|
||||
|
||||
// 获取提取码
|
||||
const extractionCode = codeType.value === 'random' ? generatedCode.value : customCode.value
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
const reqId = generateRequestId()
|
||||
|
||||
try {
|
||||
const result = await websocket.send('share_plan', {
|
||||
id: reqId,
|
||||
data: {
|
||||
plan_id: props.planId,
|
||||
expiration_days: expirationDays.value,
|
||||
extraction_code: extractionCode,
|
||||
auto_fill_code: autoFillCode.value
|
||||
}
|
||||
})
|
||||
|
||||
if (result.data?.share_url) {
|
||||
// 获取当前域名
|
||||
const baseUrl = window.location.origin
|
||||
const fullUrl = baseUrl + result.data.share_url
|
||||
|
||||
// 复制到剪贴板
|
||||
try {
|
||||
await navigator.clipboard.writeText(fullUrl)
|
||||
ElMessage.success('链接已复制到剪贴板')
|
||||
dialogVisible.value = false
|
||||
} catch {
|
||||
ElMessage.error('复制失败,请手动复制')
|
||||
}
|
||||
} else {
|
||||
error.value = '生成分享链接失败'
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = err.message || '生成分享链接失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 对话框关闭后重置状态
|
||||
const handleClosed = () => {
|
||||
loading.value = false
|
||||
error.value = ''
|
||||
expirationDays.value = 7
|
||||
codeType.value = 'random'
|
||||
customCode.value = ''
|
||||
generatedCode.value = ''
|
||||
autoFillCode.value = true
|
||||
}
|
||||
|
||||
// 暴露方法供外部调用
|
||||
defineExpose({
|
||||
close: () => {
|
||||
dialogVisible.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.share-content {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
.setting-row {
|
||||
margin-bottom: 20px;
|
||||
|
||||
&.inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
.setting-label {
|
||||
margin-bottom: 0;
|
||||
margin-right: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.option-box {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
.code-option-box {
|
||||
flex: none;
|
||||
}
|
||||
.custom-code-wrapper {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.option-box {
|
||||
display: flex;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
|
||||
.option-item {
|
||||
flex: 1;
|
||||
padding: 10px 0;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
border-right: 1px solid var(--color-border);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-bg-subtle);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.code-option-box {
|
||||
display: flex;
|
||||
width: 160px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
|
||||
.option-item {
|
||||
flex: 1;
|
||||
padding: 10px 0;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
border-right: 1px solid var(--color-border);
|
||||
transition: all 0.2s;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-bg-subtle);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-code-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 8px;
|
||||
|
||||
.custom-code-input {
|
||||
width: 100px;
|
||||
|
||||
:deep(.el-input__wrapper) {
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-input__suffix) {
|
||||
right: 4px;
|
||||
}
|
||||
|
||||
:deep(.el-input__inner) {
|
||||
&::placeholder {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.code-count {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.code-hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-error, #f56c6c);
|
||||
margin-left: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.auto-fill-checkbox {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.generate-btn {
|
||||
margin-top: 24px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 0;
|
||||
gap: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
.el-icon {
|
||||
font-size: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.success-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
|
||||
.success-icon {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.success-text {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.share-link-box {
|
||||
width: 100%;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.link-input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.tip-text {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 0;
|
||||
gap: 12px;
|
||||
|
||||
.error-text {
|
||||
color: var(--color-error, #f56c6c);
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user