v0.5.4: 全面修复 — template literal URL, Cookie验证, 用户默认is_active, 默认账号路由, 空间信息, 密钥清理, promoForm修复

修复:
- quark-share.ts/storage.ts: 9处template literal ${}缺失导致fetch URL写死
- user/routes.ts: testCloudConnectionWithCookie缺await + 按cloudType分发驱动
- credential.service.ts: INSERT缺?参数 (9values/10cols)
- user/routes.ts: 用户新增网盘默认is_active=0
- admin.routes.ts: 新增PUT /admin/cloud-configs/:id/primary路由
- database.ts: is_primary列迁移
- UserDashboard.vue: 保存时传递storage_used/storage_total
- SystemConfig.vue: promoForm const重赋值bug
- config/index.ts: 移除泄露的默认密钥token
This commit is contained in:
2026-05-19 23:09:11 +08:00
parent 39724e6e73
commit d7b055f88b
212 changed files with 4337 additions and 51 deletions

View File

@@ -383,6 +383,38 @@ export async function changePassword(
return data
}
// ===== 推广平台管理 =====
export interface PromotionPlatform {
id: number
name: string
join_url: string
sort_order: number
created_at?: string
}
export async function getPromotionPlatforms(): Promise<PromotionPlatform[]> {
const { data } = await axios.get('/api/promotion-platforms')
return data
}
export async function getAdminPromotionPlatforms(): Promise<PromotionPlatform[]> {
const { data } = await api.get('/admin/promotion-platforms')
return data
}
export async function createPromotionPlatform(params: { name: string; join_url: string; sort_order?: number }): Promise<PromotionPlatform> {
const { data } = await api.post('/admin/promotion-platforms', params)
return data
}
export async function updatePromotionPlatform(id: number, params: { name: string; join_url: string; sort_order?: number }): Promise<void> {
await api.put(`/admin/promotion-platforms/${id}`, params)
}
export async function deletePromotionPlatform(id: number): Promise<void> {
await api.delete(`/admin/promotion-platforms/${id}`)
}
export default api
export { query as searchQuery }

View File

@@ -307,7 +307,6 @@ async function switchTrendDays(d: number) {
}
// Menu selection
const activeMenu = ref('dashboard')
const activeSystemSection = ref('')
// ── Recent saves ──
@@ -381,6 +380,7 @@ const sysSectionTitles: Record<string, string> = {
'sys-validation': '链接验证配置',
'sys-filter': '搜索标题过滤规则',
'sys-password': '修改管理员密码',
'sys-platforms': '推广平台管理',
}
const pageTitle = computed(() => {

View File

@@ -42,6 +42,7 @@
<el-menu-item index="sys-password">🔑 修改密码</el-menu-item>
<el-menu-item index="sys-notify">📬 消息推送</el-menu-item>
<el-menu-item index="sys-daily-report">📊 每日汇报</el-menu-item>
<el-menu-item index="sys-platforms">👥 推广平台管理</el-menu-item>
</el-sub-menu>
<el-menu-item index="save-records">
@@ -102,6 +103,7 @@ const pageTitles: Record<string, string> = {
'sys-password': '修改管理员密码',
'sys-notify': '消息推送',
'sys-daily-report': '每日汇报',
'sys-platforms': '推广平台管理',
'save-records': '转存日志',
}

View File

@@ -21,13 +21,13 @@
</el-form-item>
<el-form-item label="白名单目录">
<div style="width: 100%;">
<div style="display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 6px;" v-if="whitelistDirs.length">
<el-tag v-for="(dir, i) in whitelistDirs" :key="i" closable size="small" @close="removeWhitelistDir(i)">{{ dir }}</el-tag>
</div>
<div style="display: flex; gap: 6px;">
<el-input v-model="newWhitelistDir" placeholder="输入目录名" size="small" style="width: 160px" @keyup.enter="addWhitelistDir" />
<el-button type="primary" size="small" @click="addWhitelistDir">添加</el-button>
</div>
<div style="display: flex; gap: 6px; flex-wrap: wrap; margin-top: 6px;" v-if="whitelistDirs.length">
<el-tag v-for="(dir, i) in whitelistDirs" :key="i" closable size="small" @close="removeWhitelistDir(i)">{{ dir }}</el-tag>
</div>
</div>
</el-form-item>
</el-form>

View File

@@ -754,6 +754,57 @@
</template>
</el-dialog>
<!-- 👥 推广平台管理 -->
<el-card id="section-sys-platforms" v-show="!activeSection || activeSection === 'sys-platforms'">
<template #header>
<span>👥 推广平台管理</span>
</template>
<div class="form-tip" style="margin-bottom: 12px;">配置注册页面可选的推广平台每个平台需提供邀请链接将自动生成二维码供用户扫码加入</div>
<div style="margin-bottom: 12px;">
<label style="font-size: 13px; color: #606266; display: block; margin-bottom: 4px;">二维码标题</label>
<el-input v-model="configs.promotion_qr_title" placeholder="扫码加入推广团队" style="max-width: 360px" />
<div class="form-tip">注册页面二维码区域的标题文字保存后生效</div>
</div>
<el-table :data="promoPlatforms" stripe size="small" empty-text="暂无推广平台">
<el-table-column prop="name" label="平台名称" width="160" />
<el-table-column prop="join_url" label="邀请链接" min-width="300" show-overflow-tooltip />
<el-table-column prop="sort_order" label="排序" width="80" align="center" />
<el-table-column label="操作" width="160" align="center">
<template #default="{ row }">
<el-button size="small" text type="primary" @click="editPromoPlatform(row)">编辑</el-button>
<el-popconfirm title="确定删除该平台?" @confirm="deletePromoPlatform(row.id)">
<template #reference>
<el-button size="small" text type="danger">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<div style="margin-top: 12px;">
<el-button size="small" type="primary" @click="showPromoDialog = true; editingPromoId = null; promoForm.name = ''; promoForm.join_url = ''; promoForm.sort_order = 0">新增平台</el-button>
</div>
</el-card>
<!-- 推广平台弹窗 -->
<el-dialog v-model="showPromoDialog" :title="editingPromoId ? '编辑推广平台' : '新增推广平台'" width="480px">
<el-form :model="promoForm" label-width="100px">
<el-form-item label="平台名称">
<el-input v-model="promoForm.name" placeholder="如:蜂小推" />
</el-form-item>
<el-form-item label="邀请链接">
<el-input v-model="promoForm.join_url" placeholder="https://..." />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="promoForm.sort_order" :min="0" :max="999" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showPromoDialog = false">取消</el-button>
<el-button type="primary" :loading="promoSaving" @click="savePromoPlatform">保存</el-button>
</template>
</el-dialog>
<!-- 保存按钮 -->
<div class="save-bar">
<el-button type="primary" size="large" :loading="saving" @click="handleSave">
@@ -768,7 +819,7 @@ import { ref, reactive, onMounted, computed, watch } from "vue"
import { useRoute, useRouter } from "vue-router"
import { ElMessage } from 'element-plus'
import type { ElForm } from 'element-plus'
import { getSystemConfigs, updateSystemConfigs, changePassword as changePasswordApi, uploadFallbackImage, uploadLogo, updateSetting, getDbStatus, testRedisConnection, testExternalService, testNotifyChannel, getAllNotifierProviders, getCloudConfigs } from "../../api"
import { getSystemConfigs, updateSystemConfigs, changePassword as changePasswordApi, uploadFallbackImage, uploadLogo, updateSetting, getDbStatus, testRedisConnection, testExternalService, testNotifyChannel, getAllNotifierProviders, getCloudConfigs, getAdminPromotionPlatforms, createPromotionPlatform, updatePromotionPlatform, deletePromotionPlatform } from "../../api"
import { Upload, Loading } from "@element-plus/icons-vue"
@@ -814,6 +865,12 @@ const ipGeoTesting = ref(false)
const pansouInfo = ref<any>(null)
const pansouInfoLoading = ref(true)
const pansouUpdating = ref(false)
const promoPlatforms = ref<any[]>([])
const showPromoDialog = ref(false)
const editingPromoId = ref<number | null>(null)
const promoForm = reactive({ name: '', join_url: '', sort_order: 0 })
const promoSaving = ref(false)
const proxyEnabled = computed({
get: () => String(configs.search_proxy_enabled) === 'true',
set: (val: boolean) => { configs.search_proxy_enabled = val ? 'true' : 'false' },
@@ -1263,6 +1320,7 @@ const passwordRules = {
}
onMounted(async () => {
loadPromoPlatforms();
try {
rawConfigs.value = await getSystemConfigs()
for (const cfg of rawConfigs.value) {
@@ -1618,6 +1676,11 @@ async function handleSave() {
key: cfg.key,
value: String(configs[cfg.key] ?? cfg.value),
}))
// Always save promotion QR title
if (configs.promotion_qr_title !== undefined) {
const hasQrTitle = entries.some((e: any) => e.key === 'promotion_qr_title')
if (!hasQrTitle) entries.push({ key: 'promotion_qr_title', value: String(configs.promotion_qr_title || '') })
}
await saveDailyReportConfig()
// Add global_notify_config as JSON entry
entries.push({
@@ -1632,6 +1695,27 @@ async function handleSave() {
saving.value = false
}
}
async function loadPromoPlatforms() {
try { promoPlatforms.value = await getAdminPromotionPlatforms() } catch { /* */ }
}
function editPromoPlatform(row: any) {
editingPromoId.value = row.id; promoForm.name = row.name; promoForm.join_url = row.join_url; promoForm.sort_order = row.sort_order; showPromoDialog.value = true;
}
async function savePromoPlatform() {
if (!promoForm.name || !promoForm.join_url) { ElMessage.warning("平台名称和邀请链接不能为空"); return; }
promoSaving.value = true;
try {
if (editingPromoId.value) { await updatePromotionPlatform(editingPromoId.value, { ...promoForm }); ElMessage.success("更新成功"); }
else { await createPromotionPlatform({ ...promoForm }); ElMessage.success("添加成功"); }
showPromoDialog.value = false; editingPromoId.value = null; loadPromoPlatforms();
} catch (e: any) { ElMessage.error(e.response?.data?.error || "保存失败"); }
finally { promoSaving.value = false; }
}
async function deletePromoPlatform(id: number) {
try { await deletePromotionPlatform(id); ElMessage.success("已删除"); loadPromoPlatforms(); }
catch (e: any) { ElMessage.error(e.response?.data?.error || "删除失败"); }
}
async function handleChangePassword() {
const valid = await passwordFormRef.value?.validate().catch(() => false)

View File

@@ -0,0 +1,561 @@
<template>
<div class="user-layout">
<!-- Header -->
<div class="user-header">
<span class="user-title">CloudSearch 用户中心</span>
<div class="header-right">
<span class="user-account">{{ account }}</span>
<el-button size="small" @click="handleLogout">退出登录</el-button>
</div>
</div>
<div class="user-body">
<!-- Sidebar -->
<div class="user-sidebar">
<div
v-for="item in menuItems"
:key="item.key"
:class="['sidebar-item', { active: activeMenu === item.key }]"
@click="activeMenu = item.key"
>
<span>{{ item.icon }} {{ item.label }}</span>
</div>
</div>
<!-- Content -->
<div class="user-content">
<!-- 📋 转存日志 -->
<div v-show="activeMenu === 'records'">
<el-card shadow="never">
<template #header><span>📋 转存日志</span></template>
<el-table :data="records" stripe empty-text="暂无转存记录" size="small">
<el-table-column prop="source_title" label="资源名称" min-width="200" show-overflow-tooltip />
<el-table-column prop="source_type" label="来源" width="80" />
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 'success' ? 'success' : 'danger'" size="small">
{{ row.status === 'success' ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="file_size" label="大小" width="90" />
<el-table-column label="耗时" width="90">
<template #default="{ row }">
{{ row.duration_ms ? (row.duration_ms / 1000).toFixed(1) + 's' : '-' }}
</template>
</el-table-column>
<el-table-column label="时间" width="170">
<template #default="{ row }">{{ row.created_at }}</template>
</el-table-column>
</el-table>
<el-pagination
v-if="recordsTotal > 0"
v-model:current-page="recordsPage"
:page-size="recordsPageSize"
:total="recordsTotal"
layout="prev, pager, next"
small
style="margin-top: 16px; justify-content: center;"
@current-change="loadRecords"
/>
</el-card>
</div>
<!-- 💾 网盘管理 复刻 admin CloudConfig 布局 -->
<div v-show="activeMenu === 'drives'">
<el-card shadow="never">
<template #header><span>💾 我的网盘</span></template>
<div style="margin-bottom: 12px;">
<el-button type="primary" size="small" @click="openAddDrive">新增配置</el-button>
</div>
<el-table :data="drives" stripe empty-text="暂无网盘配置" size="small">
<el-table-column label="网盘类型" width="110">
<template #default="{ row }">
<CloudBadge :cloud_type="row.cloud_type" />
</template>
</el-table-column>
<el-table-column prop="nickname" label="昵称" width="140">
<template #default="{ row }">
<span v-if="row.nickname">{{ row.nickname }}</span>
<span v-else style="color:#909399;font-size:12px;">未设置</span>
</template>
</el-table-column>
<el-table-column prop="promotion_account" label="推广账号" width="160">
<template #default="{ row }">
<span v-if="row.promotion_account">{{ row.promotion_account }}</span>
<span v-else style="color:#909399;font-size:12px;">-</span>
</template>
</el-table-column>
<el-table-column label="验证" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="row.verification_status === 'valid'" type="success" size="small">有效</el-tag>
<el-tag v-else-if="row.verification_status === 'invalid'" type="danger" size="small">无效</el-tag>
<el-tag v-else type="info" size="small">未验证</el-tag>
</template>
</el-table-column>
<el-table-column label="空间" width="200">
<template #default="{ row }">
<template v-if="row.storage_total && row.storage_total !== '-'">
<div>{{ row.storage_used || '计算中...' }} / {{ row.storage_total }}</div>
</template>
<span v-else style="color:#909399"></span>
</template>
</el-table-column>
<el-table-column label="转存数" width="80" align="center">
<template #default="{ row }">{{ row.total_saves > 0 ? row.total_saves + '次' : '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="160" align="center">
<template #default="{ row }">
<el-button size="small" text type="primary" @click="openEditDrive(row)">编辑</el-button>
<el-popconfirm title="确定删除该配置?" @confirm="handleDeleteDrive(row.id)">
<template #reference>
<el-button size="small" text type="danger">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
<!-- 🔔 消息推送 复刻 admin SystemConfig notify 布局 -->
<div v-show="activeMenu === 'notify'">
<el-card shadow="never">
<template #header><span>🔔 消息推送配置</span></template>
<el-form label-width="120px" size="small">
<el-form-item label="推送通道">
<el-select v-model="notifyChannel" style="width: 200px" placeholder="选择推送通道">
<el-option label="Feishu Bot" value="feishu" />
</el-select>
</el-form-item>
<template v-if="notifyChannel === 'feishu'">
<el-form-item label="Webhook 地址">
<el-input v-model="notifyWebhook" placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/xxx" style="width: 420px" />
</el-form-item>
<el-form-item label="推送事件">
<el-checkbox-group v-model="notifyEvents">
<el-checkbox label="on_save_success">转存成功</el-checkbox>
<el-checkbox label="on_save_fail">转存失败</el-checkbox>
<el-checkbox label="on_cookie_expire">Cookie过期</el-checkbox>
</el-checkbox-group>
</el-form-item>
</template>
<el-form-item>
<el-button type="primary" :loading="savingNotify" @click="saveNotify">保存推送配置</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</div>
</div>
<!-- 新增/编辑网盘弹窗 复刻 admin CloudConfig dialog -->
<el-dialog v-model="showAddDrive" :title="editingDrive ? '编辑网盘配置' : '新增网盘配置'" width="560px">
<el-form ref="driveFormRef" :model="driveForm" :rules="driveRules" label-width="120px">
<el-form-item label="网盘类型" prop="cloud_type">
<el-select v-model="driveForm.cloud_type" style="width: 100%" :disabled="!!editingDrive" @change="onDriveTypeChange">
<el-option
v-for="ct in enabledCloudTypes"
:key="ct.type"
:label="ct.label"
:value="ct.type"
/>
</el-select>
</el-form-item>
<el-form-item label="Cookie" prop="cookie">
<el-input
v-model="driveForm.cookie"
type="textarea"
:autosize="{ minRows: 2, maxRows: 4 }"
:placeholder="driveCookiePlaceholder"
input-style="font-family: monospace; font-size: 12px;"
/>
</el-form-item>
<!-- Cookie 获取教程根据网盘类型切换 -->
<el-form-item label=" " v-if="driveForm.cloud_type && driveForm.cloud_type !== ''" class="cookie-tips-item">
<div class="cookie-tips" :class="`cookie-tips-${driveForm.cloud_type}`">
<div class="cookie-tips-header">
<span class="cookie-tips-title">📖 {{ driveCloudTypeLabel }} Cookie 获取教程</span>
</div>
<ol class="cookie-tips-steps" v-html="driveCookieTutorialHtml"></ol>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAddDrive = false; editingDrive = null">取消</el-button>
<el-button type="primary" :loading="adding" @click="handleSaveDrive">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import axios from 'axios'
import { CLOUD_LABELS } from '../../types'
import type { CloudType } from '../../types'
import CloudBadge from '../../components/CloudBadge.vue'
import type { ElForm } from 'element-plus'
const router = useRouter()
const account = ref('')
function userApi() {
const token = localStorage.getItem('user_token')
return axios.create({
baseURL: '/api/user',
headers: { Authorization: `Bearer ${token}` },
})
}
onMounted(async () => {
const token = localStorage.getItem('user_token')
if (!token) { router.push('/user/login'); return }
try {
const { data } = await userApi().get('/profile')
account.value = data.account
} catch {
localStorage.removeItem('user_token')
localStorage.removeItem('user_account')
router.push('/user/login')
}
})
function handleLogout() {
localStorage.removeItem('user_token')
localStorage.removeItem('user_account')
router.push('/user/login')
}
// ── Menu ──
const activeMenu = ref('records')
const menuItems = [
{ key: 'records', icon: '📋', label: '转存日志' },
{ key: 'drives', icon: '💾', label: '网盘管理' },
{ key: 'notify', icon: '🔔', label: '消息推送' },
]
// ── 转存日志 ──
const records = ref<any[]>([])
const recordsTotal = ref(0)
const recordsPage = ref(1)
const recordsPageSize = ref(20)
async function loadRecords() {
try {
const { data } = await userApi().get('/save-records', { params: { page: recordsPage.value, pageSize: recordsPageSize.value } })
records.value = data.records
recordsTotal.value = data.total
} catch { /* */ }
}
// ── 网盘管理 ──
const drives = ref<any[]>([])
const showAddDrive = ref(false)
const editingDrive = ref<any>(null)
const adding = ref(false)
const driveFormRef = ref<InstanceType<typeof ElForm>>()
interface EnabledCloudType { type: string; label: string; icon: string; enabled: boolean }
const enabledCloudTypes = ref<EnabledCloudType[]>([])
const defaultDriveForm = () => ({
cloud_type: '' as CloudType | '',
cookie: '',
})
const driveForm = reactive(defaultDriveForm())
const driveRules = computed(() => ({
cloud_type: [{ required: true, message: '请选择网盘类型', trigger: 'change' }],
}))
const driveCookiePlaceholder = computed(() => {
if (!driveForm.cloud_type) return '请先选择网盘类型'
const t = driveForm.cloud_type
if (t === 'quark' || t === 'baidu') return `请输入 ${CLOUD_LABELS[t] || t} 的完整 Cookie`
return editingDrive.value ? '留空则保持原有' : '输入完整 Cookie'
})
const driveCloudTypeLabel = computed(() => {
return CLOUD_LABELS[driveForm.cloud_type as CloudType] || driveForm.cloud_type || ''
})
/** Cookie 获取教程 HTML根据不同网盘类型 */
const driveCookieTutorialHtml = computed(() => {
const t = driveForm.cloud_type
if (!t) return ''
const tutorials: Record<string, string> = {
quark: `<li>在电脑上打开 <a href="https://pan.quark.cn" target="_blank">pan.quark.cn</a> 并登录你的夸克账号</li>
<li>按 <code>F12</code> 打开开发者工具 → 切换到 <strong>网络 (Network)</strong> 选项卡</li>
<li>刷新页面,在请求列表中点击任意一个请求(如 <code>account/info</code></li>
<li>在右侧 <strong>请求头 (Request Headers)</strong> 中找到 <code>Cookie</code> 字段</li>
<li>复制整个 Cookie 值(<b>从开头到结束的完整内容</b>),粘贴到上方输入框</li>
<li>点击「<b>保存</b>」按钮提交配置</li>
<div class="cookie-tips-note">⚠️ 必须包含 <code>__st=s%...</code> 字段!请复制浏览器请求头的 <b>整个 Cookie</b>F12 → Network → 请求头 → Cookie 项),不要只复制部分。</div>`,
baidu: `<li>在电脑上打开 <a href="https://pan.baidu.com" target="_blank">pan.baidu.com</a> 并登录你的百度账号</li>
<li>按 <code>F12</code> 打开开发者工具 → 切换到 <strong>网络 (Network)</strong> 选项卡</li>
<li>刷新页面,在请求列表中点击任意一个请求</li>
<li>在右侧 <strong>请求头 (Request Headers)</strong> 中找到 <code>Cookie</code> 字段</li>
<li>复制整个 Cookie 值,粘贴到上方输入框</li>
<li>点击「<b>保存</b>」按钮提交配置</li>
<div class="cookie-tips-note">💡 需要包含 <code>BDUSS</code> 和 <code>STOKEN</code></div>`,
aliyun: `<li>在电脑上打开 <a href="https://www.aliyundrive.com" target="_blank">aliyundrive.com</a> 并登录</li>
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
<li>粘贴到上方输入框,点击「保存」提交</li>
<div class="cookie-tips-note">💡 需包含 <code>token</code> 等有效字段</div>`,
'115': `<li>在电脑上打开 <a href="https://115.com" target="_blank">115.com</a> 并登录</li>
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
<li>粘贴到上方输入框,点击「保存」提交</li>
<div class="cookie-tips-note">💡 需包含 <code>UID</code>、<code>CID</code>、<code>SEID</code> 等字段</div>`,
tianyi: `<li>在电脑上打开 <a href="https://cloud.189.cn" target="_blank">cloud.189.cn</a> 并登录</li>
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
<li>粘贴到上方输入框,点击「保存」提交</li>
<div class="cookie-tips-note">💡 需包含 <code>COOKIE_LOGIN_USER</code>、<code>SESSION</code> 等字段</div>`,
'123pan': `<li>在电脑上打开 <a href="https://www.123pan.com" target="_blank">123pan.com</a> 并登录</li>
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
<li>粘贴到上方输入框,点击「保存」提交</li>`,
uc: `<li>在电脑上打开 <a href="https://drive.uc.cn" target="_blank">drive.uc.cn</a> 并登录</li>
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
<li>粘贴到上方输入框,点击「保存」提交</li>`,
xunlei: `<li>在电脑上打开 <a href="https://pan.xunlei.com" target="_blank">pan.xunlei.com</a> 并登录</li>
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
<li>粘贴到上方输入框,点击「保存」提交</li>`,
pikpak: `<li>在电脑上打开 <a href="https://www.mypikpak.com" target="_blank">mypikpak.com</a> 并登录</li>
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
<li>刷新页面,找到任意请求 → 复制 <code>Cookie</code></li>
<li>粘贴到上方输入框,点击「保存」提交</li>`,
}
return tutorials[t] || `<li>在电脑上打开该网盘网站并登录</li>
<li>按 <code>F12</code> 打开开发者工具 → <strong>网络 (Network)</strong></li>
<li>刷新页面,复制任意请求的 <code>Cookie</code></li>
<li>粘贴到上方输入框,点击「保存」提交</li>`
})
async function loadEnabledCloudTypes() {
try {
const { data } = await userApi().get('/enabled-cloud-types')
enabledCloudTypes.value = data
} catch { enabledCloudTypes.value = [] }
}
async function loadDrives() {
try {
const { data } = await userApi().get('/cloud-configs')
drives.value = data
} catch { /* */ }
}
function openAddDrive() {
editingDrive.value = null
Object.assign(driveForm, defaultDriveForm())
showAddDrive.value = true
}
function openEditDrive(row: any) {
editingDrive.value = row
driveForm.cloud_type = row.cloud_type
driveForm.cookie = ''
showAddDrive.value = true
}
function onDriveTypeChange() {
// placeholder auto-updates via computed
}
async function handleSaveDrive() {
const valid = await driveFormRef.value?.validate().catch(() => false)
if (!valid) return
adding.value = true
try {
let verifyNickname = ''
let verifyStorageUsed = ''
let verifyStorageTotal = ''
// 如果有 Cookie先验证并自动获取昵称和空间信息
if (driveForm.cookie) {
try {
const api = userApi()
const { data: verifyResult } = await api.post(`/cloud-configs/${driveForm.cloud_type}/test`, { cookie: driveForm.cookie })
if (!verifyResult.success) {
ElMessage.error(`Cookie验证失败${verifyResult.message}`)
adding.value = false
return
}
// 自动获取网盘昵称和空间信息
if (verifyResult.nickname) verifyNickname = verifyResult.nickname
if (verifyResult.storage_used) verifyStorageUsed = verifyResult.storage_used
if (verifyResult.storage_total) verifyStorageTotal = verifyResult.storage_total
} catch (e: any) {
ElMessage.error(`Cookie验证失败${e.response?.data?.error || '网络错误'}`)
adding.value = false
return
}
}
const promotionAccount = account.value // 自动使用当前用户的「推广平台-手机号」
if (editingDrive.value) {
await userApi().put(`/cloud-configs/${editingDrive.value.id}`, {
cloud_type: driveForm.cloud_type,
nickname: verifyNickname || undefined,
promotion_account: promotionAccount,
cookie: driveForm.cookie || undefined,
storage_used: verifyStorageUsed || undefined,
storage_total: verifyStorageTotal || undefined,
})
ElMessage.success('更新成功')
} else {
if (!driveForm.cookie) { ElMessage.warning('请填写Cookie'); adding.value = false; return }
await userApi().post('/cloud-configs', {
cloud_type: driveForm.cloud_type,
cookie: driveForm.cookie,
nickname: verifyNickname || undefined,
promotion_account: promotionAccount,
storage_used: verifyStorageUsed || undefined,
storage_total: verifyStorageTotal || undefined,
})
ElMessage.success('添加成功')
}
showAddDrive.value = false
editingDrive.value = null
loadDrives()
} catch (e: any) {
ElMessage.error(e.response?.data?.error || '保存失败')
} finally { adding.value = false }
}
async function handleDeleteDrive(id: number) {
try {
await userApi().delete(`/cloud-configs/${id}`)
ElMessage.success('已删除')
loadDrives()
} catch (e: any) { ElMessage.error(e.response?.data?.error || '删除失败') }
}
// ── 推送配置 ──
const notifyChannel = ref('')
const notifyWebhook = ref('')
const notifyEvents = ref<string[]>([])
const savingNotify = ref(false)
async function loadNotify() {
try {
const { data } = await userApi().get('/notify-config')
const cfg = JSON.parse(data.notifyConfig || '{}')
if (cfg.webhook) {
notifyChannel.value = 'feishu'
notifyWebhook.value = cfg.webhook
notifyEvents.value = cfg.events || []
}
} catch { /* */ }
}
async function saveNotify() {
savingNotify.value = true
try {
const config = notifyChannel.value === 'feishu'
? JSON.stringify({ webhook: notifyWebhook.value, events: notifyEvents.value })
: '{}'
await userApi().put('/notify-config', { notifyConfig: config })
ElMessage.success('推送配置已保存')
} catch (e: any) { ElMessage.error(e.response?.data?.error || '保存失败') }
finally { savingNotify.value = false }
}
watch(activeMenu, (tab) => {
if (tab === 'records') loadRecords()
else if (tab === 'drives') { loadEnabledCloudTypes(); loadDrives() }
else if (tab === 'notify') loadNotify()
}, { immediate: true })
</script>
<style scoped>
.user-layout { min-height: 100vh; background: #f0f2f5; }
.user-header {
display: flex; justify-content: space-between; align-items: center;
padding: 0 24px; height: 56px; background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.user-title { font-size: 16px; font-weight: 600; color: #303133; }
.header-right { display: flex; align-items: center; gap: 12px; }
.user-account { font-size: 13px; color: #606266; }
.user-body { display: flex; min-height: calc(100vh - 56px); }
.user-sidebar {
width: 180px; background: #fff; border-right: 1px solid #e4e7ed;
padding: 12px 0; flex-shrink: 0;
}
.sidebar-item {
padding: 10px 20px; cursor: pointer; font-size: 14px; color: #606266;
transition: all 0.2s;
}
.sidebar-item:hover { background: #f5f7fa; color: #303133; }
.sidebar-item.active { background: #ecf5ff; color: #409eff; font-weight: 500; border-right: 2px solid #409eff; }
.user-content { flex: 1; padding: 20px; overflow: auto; }
/* ── Cookie tutorial card (same as admin CloudConfig) ── */
.cookie-tips-item :deep(.el-form-item__content) {
margin-left: 0 !important;
}
.cookie-tips {
background: #f8faff;
border: 1px solid #e8f0fe;
border-radius: 6px;
padding: 14px 16px;
font-size: 12px;
line-height: 1.8;
color: #606266;
width: 100%;
box-sizing: border-box;
}
.cookie-tips-header {
margin-bottom: 10px;
}
.cookie-tips-title {
font-weight: 700;
color: #409eff;
font-size: 13px;
}
.cookie-tips-steps {
margin: 0;
padding-left: 20px;
}
.cookie-tips-steps li {
margin-bottom: 4px;
}
.cookie-tips-steps code {
background: #ecf5ff;
padding: 1px 5px;
border-radius: 3px;
font-size: 11px;
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
}
.cookie-tips-note {
margin-top: 8px;
padding: 6px 10px;
background: #fffbe6;
border: 1px solid #fff3c4;
border-radius: 4px;
color: #8a6d3b;
font-size: 11px;
line-height: 1.5;
}
.cookie-tips-note code {
background: #f5f0e0;
font-size: 11px;
}
</style>

View File

@@ -0,0 +1,215 @@
<template>
<div class="user-login-page">
<div class="login-card">
<h1 class="login-title">CloudSearch 用户中心</h1>
<p class="login-subtitle">{{ isLogin ? '登录您的账号' : '注册新账号' }}</p>
<el-form ref="formRef" :model="form" :rules="rules" label-width="0" @keyup.enter="handleSubmit">
<el-form-item prop="platform">
<el-select v-model="form.platform" placeholder="选择推广平台" size="large" style="width: 100%">
<el-option
v-for="p in platforms"
:key="p.name"
:label="p.name"
:value="p.name"
/>
</el-select>
</el-form-item>
<el-form-item prop="phone">
<el-input v-model="form.phone" placeholder="手机号码" size="large" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" type="password" placeholder="密码" size="large" show-password />
</el-form-item>
<el-form-item v-if="!isLogin" prop="confirmPassword">
<el-input v-model="form.confirmPassword" type="password" placeholder="确认密码" size="large" show-password />
</el-form-item>
<el-form-item>
<el-button type="primary" size="large" :loading="loading" style="width: 100%" @click="handleSubmit">
{{ isLogin ? ' ' : ' ' }}
</el-button>
</el-form-item>
</el-form>
<div class="login-switch">
{{ isLogin ? '还没有账号?' : '已有账号?' }}
<el-button link type="primary" @click="isLogin = !isLogin">
{{ isLogin ? '立即注册' : '去登录' }}
</el-button>
</div>
</div>
<!-- 二维码区域 -->
<div v-if="platforms.length > 0" class="qr-section">
<div class="qr-title">{{ qrTitle }}</div>
<div class="qr-grid">
<div v-for="p in platforms" :key="p.name" class="qr-item">
<div class="qr-label">加入{{ p.name }}团队</div>
<div class="qr-img-wrap">
<img
:src="'https://api.qrserver.com/v1/create-qr-code/?size=120x120&data=' + encodeURIComponent(p.join_url)"
:alt="'加入' + p.name"
class="qr-image"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import axios from 'axios'
const router = useRouter()
const isLogin = ref(true)
const loading = ref(false)
const formRef = ref()
interface Platform {
id: number
name: string
join_url: string
sort_order: number
}
const platforms = ref<Platform[]>([])
const qrTitle = ref('扫码加入推广团队')
const form = reactive({
platform: '',
phone: '',
password: '',
confirmPassword: '',
})
const validatePhone = (_rule: any, value: string, callback: any) => {
if (!value) callback(new Error('请输入手机号码'))
else if (!/^1[3-9]\d{9}$/.test(value)) callback(new Error('请输入有效的手机号码'))
else callback()
}
const rules = {
platform: [{ required: true, message: '请选择推广平台', trigger: 'change' }],
phone: [{ validator: validatePhone, trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }, { min: 6, message: '密码至少6位', trigger: 'blur' }],
confirmPassword: [{
validator: (_rule: any, value: string, callback: any) => {
if (!value) callback(new Error('请确认密码'))
else if (value !== form.password) callback(new Error('两次密码不一致'))
else callback()
}, trigger: 'blur'
}],
}
onMounted(async () => {
try {
const { data } = await axios.get('/api/promotion-platforms')
platforms.value = data.platforms
if (data.title) qrTitle.value = data.title
} catch { /* */ }
})
async function handleSubmit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
if (isLogin.value) {
const account = form.platform + '-' + form.phone.trim()
const { data } = await axios.post('/api/user/login', {
account,
password: form.password,
})
localStorage.setItem('user_token', data.token)
localStorage.setItem('user_account', account)
ElMessage.success('登录成功')
router.push('/user/dashboard')
} else {
const { data } = await axios.post('/api/user/register', {
platform: form.platform,
phone: form.phone.trim(),
password: form.password,
})
ElMessage.success(data.message || '注册成功,请登录')
isLogin.value = true
form.password = ''
form.confirmPassword = ''
}
} catch (e: any) {
ElMessage.error(e.response?.data?.error || '操作失败')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.user-login-page {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #f0f2f5;
padding: 40px 20px;
}
.login-card {
width: 420px;
padding: 40px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
}
.login-title { text-align: center; font-size: 24px; margin: 0 0 8px; color: #303133; }
.login-subtitle { text-align: center; font-size: 14px; color: #909399; margin: 0 0 32px; }
.login-switch { text-align: center; font-size: 14px; color: #909399; margin-top: 16px; }
.qr-section {
margin-top: 40px;
text-align: center;
width: 100%;
max-width: 900px;
}
.qr-title {
font-size: 16px;
font-weight: 600;
color: #303133;
margin-bottom: 24px;
}
.qr-grid {
display: flex;
justify-content: center;
gap: 30px;
flex-wrap: wrap;
}
.qr-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.qr-label {
font-size: 14px;
color: #606266;
font-weight: 500;
}
.qr-img-wrap {
padding: 12px;
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 6px rgba(0,0,0,0.08);
}
.qr-image {
display: block;
width: 100px;
height: 100px;
}
</style>

View File

@@ -57,6 +57,20 @@ const routes = [
},
],
},
{
path: '/user/login',
name: 'user-login',
component: () => import('./pages/user/UserLogin.vue'),
},
{
path: '/user/dashboard',
name: 'user-dashboard',
component: () => import('./pages/user/UserDashboard.vue'),
},
{
path: '/user',
redirect: '/user/login',
},
]
const router = createRouter({