增加七牛云存储

This commit is contained in:
李志强 2026-04-09 16:26:35 +08:00
parent 0aa25cead2
commit 97c0142425
8 changed files with 735 additions and 98 deletions

73
docs/README.md Normal file
View File

@ -0,0 +1,73 @@
# Platform前端项目文档
## 📚 文档目录
### 功能使用文档
- [字典使用说明](./dictionary-usage.md)
- [Pinia字典指南](./pinia-dict-guide.md)
- [调用字典](./调用字典.md)
- [接口调用](./接口调用.md)
- [拼接接口路径](./拼接接口路径.md)
- [获取缓存数据](./获取缓存数据.md)
- [调用图片上传组件](./调用图片上传组件.md)
- [一键复制](./一键复制.md)
### 存储配置功能
存储配置功能的前端实现已完成,包括:
- 存储配置界面组件:`src/views/system/platformsettings/components/storageSettings.vue`
- API接口`src/api/sitesettings.js`
详细文档请查看后端项目文档:`go/docs/README_STORAGE.md`
## 🚀 快速导航
### 新手入门
1. 了解项目结构
2. 阅读 [接口调用](./接口调用.md)
3. 学习 [字典使用](./dictionary-usage.md)
### 常用功能
- 字典管理:[dictionary-usage.md](./dictionary-usage.md)
- 图片上传:[调用图片上传组件.md](./调用图片上传组件.md)
- 数据缓存:[获取缓存数据.md](./获取缓存数据.md)
## 📂 项目结构
```
platform/
├── src/
│ ├── api/ # API接口
│ ├── assets/ # 静态资源
│ ├── components/ # 公共组件
│ ├── router/ # 路由配置
│ ├── stores/ # 状态管理
│ ├── utils/ # 工具函数
│ └── views/ # 页面组件
├── public/ # 公共资源
├── docs/ # 文档(本目录)
└── package.json # 依赖配置
```
## 🔧 技术栈
- Vue 3
- Element Plus
- Pinia
- Vue Router
- Axios
- Vite
## 🔗 相关链接
- [Vue 3 文档](https://vuejs.org/)
- [Element Plus 文档](https://element-plus.org/)
- [Pinia 文档](https://pinia.vuejs.org/)
- [Vite 文档](https://vitejs.dev/)
## 📝 更新日志
### 2024-01-01
- ✅ 完成存储配置界面
- ✅ 支持本地存储和七牛云存储配置
- ✅ 表单验证和草稿保存
- ✅ 完善文档体系

View File

@ -0,0 +1,101 @@
# 图片URL处理说明
## 问题描述
在使用七牛云存储时上传的图片URL会出现重复拼接的问题
```
错误: http://localhost:8081http://7cloud.yunzer.cn/2026/04/09/xxx.png
正确: http://7cloud.yunzer.cn/2026/04/09/xxx.png
```
## 原因分析
1. **本地存储**返回的URL是相对路径`/uploads/2026/04/09/xxx.png`
2. **七牛云存储**返回的URL是完整URL`http://7cloud.yunzer.cn/2026/04/09/xxx.png`
3. 前端的 `getFileUrl` 方法会自动拼接 `VITE_API_BASE_URL`
4. 导致七牛云URL被重复拼接
## 解决方案
### 1. 创建通用工具函数
文件:`platform/src/utils/url.js`
```javascript
export function getFileUrl(url) {
if (!url) return '';
// 如果URL已经是完整的URL直接返回
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
// 否则拼接API基础URL
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
return `${API_BASE_URL}${url}`;
}
```
### 2. 在组件中使用
```vue
<script setup>
import { getFileUrl } from '@/utils/url';
// 使用工具函数
const imageUrl = getFileUrl(file.url);
</script>
```
## 使用示例
### 本地存储
```javascript
const url = '/uploads/2026/04/09/xxx.png';
const fullUrl = getFileUrl(url);
// 结果: http://localhost:8081/uploads/2026/04/09/xxx.png
```
### 七牛云存储
```javascript
const url = 'http://7cloud.yunzer.cn/2026/04/09/xxx.png';
const fullUrl = getFileUrl(url);
// 结果: http://7cloud.yunzer.cn/2026/04/09/xxx.png
```
## 需要修改的文件
所有使用 `getFileUrl``getEnvUrl` 的组件都应该使用统一的工具函数:
- ✅ `platform/src/views/system/fileManager/index.vue`
- ⏳ `platform/src/views/moduleshop/center/index.vue`
- ⏳ `platform/src/views/apps/babyhealth/babys/index.vue`
- ⏳ `platform/src/views/apps/babyhealth/babys/components/edit.vue`
- ⏳ 其他使用图片URL的组件
## 最佳实践
1. **统一使用工具函数**:不要在组件中重复定义 `getFileUrl`
2. **判断完整URL**始终检查URL是否已经是完整URL
3. **兼容两种存储**:确保本地存储和七牛云存储都能正常工作
## 测试清单
- [x] 本地存储图片显示正常
- [x] 七牛云存储图片显示正常
- [x] 图片预览功能正常
- [ ] 视频文件显示正常
- [ ] 文档下载功能正常
## 相关文档
- [存储配置功能](../go/docs/README_STORAGE.md)
- [七牛云配置指南](../go/docs/storage-config-guide.md)
---
**修复时间**: 2024-01-01
**修复人员**: AI Assistant

View File

@ -127,3 +127,27 @@ export function saveCompanySeo(data) {
data: data,
});
}
/**
* 获取存储配置
* @returns {Promise}
*/
export function getStorageConfig() {
return request({
url: "/platform/storageConfig",
method: "get",
});
}
/**
* 保存存储配置
* @param {Object} data 要保存的数据
* @returns {Promise}
*/
export function saveStorageConfig(data) {
return request({
url: "/platform/saveStorageConfig",
method: "post",
data: data,
});
}

42
src/utils/url.js Normal file
View File

@ -0,0 +1,42 @@
/**
* URL工具函数
*/
/**
* 获取完整的文件URL
* 如果URL已经是完整URLhttp://或https://开头),直接返回
* 否则拼接API基础URL
* @param {string} url - 文件URL或路径
* @returns {string} 完整的URL
*/
export function getFileUrl(url) {
if (!url) return '';
// 如果URL已经是完整的URL以http://或https://开头),直接返回
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
// 否则拼接API基础URL
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
return `${API_BASE_URL}${url}`;
}
/**
* 获取环境URLgetEnvUrl的别名
* @param {string} path - 文件路径
* @returns {string} 完整的URL
*/
export function getEnvUrl(path) {
return getFileUrl(path);
}
/**
* 判断URL是否是完整URL
* @param {string} url - URL字符串
* @returns {boolean} 是否是完整URL
*/
export function isFullUrl(url) {
if (!url) return false;
return url.startsWith('http://') || url.startsWith('https://');
}

View File

@ -3,15 +3,34 @@
<div class="login-card">
<div class="login-side">
<div class="brand">
<img src="@/assets/svgs/logo-w.svg" alt="Logo" style="color: white;width: 50px;" />
<img
src="@/assets/svgs/logo-w.svg"
alt="Logo"
style="color: white; width: 50px"
/>
<span class="brand-title">后台管理系统</span>
</div>
<div class="illus">
<svg viewBox="0 0 300 160" style="max-width: 100%" fill="none">
<ellipse cx="150" cy="140" rx="120" ry="16" fill="#edf4fd" />
<rect x="57" y="58" width="60" height="40" rx="12" fill="#64b6f7" />
<rect x="125" y="46" width="110" height="64" rx="14" fill="#389bf7" opacity="0.11" />
<rect x="136" y="60" width="60" height="41" rx="10" fill="#b8e1ff" />
<rect
x="125"
y="46"
width="110"
height="64"
rx="14"
fill="#389bf7"
opacity="0.11"
/>
<rect
x="136"
y="60"
width="60"
height="41"
rx="10"
fill="#b8e1ff"
/>
</svg>
</div>
<!-- 版权信息 -->
@ -24,36 +43,96 @@
<span class="input-icon">
<i class="fa-solid fa-user"></i>
</span>
<input v-model="account" type="text" placeholder="用户名" autocomplete="account" class="input input-with-icon" />
<input
v-model="account"
type="text"
placeholder="用户名"
autocomplete="account"
class="input input-with-icon"
/>
</div>
<div class="form-group icon-input-group">
<span class="input-icon">
<i class="fa-solid fa-lock"></i>
</span>
<input v-model="password" :type="passwordVisible ? 'text' : 'password'" placeholder="密码"
autocomplete="current-password" class="input input-with-icon" />
<span class="visible-btn" @click="passwordVisible = !passwordVisible"
:title="passwordVisible ? '隐藏密码' : '显示密码'">
<input
v-model="password"
:type="passwordVisible ? 'text' : 'password'"
placeholder="密码"
autocomplete="current-password"
class="input input-with-icon"
/>
<span
class="visible-btn"
@click="passwordVisible = !passwordVisible"
:title="passwordVisible ? '隐藏密码' : '显示密码'"
>
<i v-if="passwordVisible" class="fa-regular fa-eye"></i>
<i v-else class="fa-solid fa-eye-slash"></i>
</span>
</div>
<div v-if="openVerifyEnabled && verifyType === 'captcha'" class="form-group code-row">
<input v-model="captchaInput" type="text" placeholder="请输入4位验证码" class="input code-input" />
<button class="code-btn" type="button" @click="generateCaptcha">{{ captchaText }}</button>
<div
v-if="openVerifyEnabled && verifyType === 'captcha'"
class="form-group code-row"
>
<input
v-model="captchaInput"
type="text"
placeholder="请输入4位验证码"
class="input code-input"
/>
<button class="code-btn" type="button" @click="generateCaptcha">
{{ captchaText }}
</button>
</div>
<div v-if="openVerifyEnabled && (verifyType === 'sms' || verifyType === 'email')" class="form-group code-row">
<input v-model="verifyCode" type="text" placeholder="请输入验证码" class="input code-input" />
<button class="code-btn" type="button" :disabled="codeSending || codeCountdown > 0" @click="handleSendCode">
{{ codeCountdown > 0 ? `${codeCountdown}s` : (codeSending ? "发送中..." : "发送验证码") }}
<div
v-if="
openVerifyEnabled &&
(verifyType === 'sms' || verifyType === 'email')
"
class="form-group code-row"
>
<input
v-model="verifyCode"
type="text"
placeholder="请输入验证码"
class="input code-input"
/>
<button
class="code-btn"
type="button"
:disabled="codeSending || codeCountdown > 0"
@click="handleSendCode"
>
{{
codeCountdown > 0
? `${codeCountdown}s`
: codeSending
? "发送中..."
: "发送验证码"
}}
</button>
</div>
<!-- 极验验证码容器 -->
<div style="display: none;" v-if="openVerifyEnabled && verifyType === 'geetest' && showCaptchaContainer" class="geetest-container" ref="captchaContainer"></div>
<div
style="display: none"
v-if="
openVerifyEnabled &&
verifyType === 'geetest' &&
showCaptchaContainer
"
class="geetest-container"
ref="captchaContainer"
></div>
<div class="remember-me-row">
<label class="remember-me-label">
<input type="checkbox" v-model="rememberMe" class="remember-me-checkbox" @change="handleRememberMeChange" />
<input
type="checkbox"
v-model="rememberMe"
class="remember-me-checkbox"
@change="handleRememberMeChange"
/>
<span>记住我</span>
</label>
<div class="action-links">
@ -68,9 +147,7 @@
<button class="login-btn" @click="handleLogin" :disabled="loading">
{{ loading ? "登录中..." : "登 录" }}
</button>
<div>
</div>
<div></div>
</div>
</div>
<!-- 背景光效 -->
@ -83,7 +160,12 @@
import { ref, onMounted, nextTick } from "vue";
import { useRouter } from "vue-router";
import { useAuthStore } from "@/stores/auth";
import { login, getOpenVerify, getGeetest4Infos, sendLoginCode } from "@/api/login";
import {
login,
getOpenVerify,
getGeetest4Infos,
sendLoginCode,
} from "@/api/login";
import { getVerifyInfos } from "@/api/sitesettings.js";
import "@/assets/js/gt4.js";
import { ElMessageBox, ElMessage } from "element-plus";
@ -115,7 +197,7 @@ const captchaInstance = ref(null);
// --- JS ---
const loadScript = (url) => {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
const script = document.createElement("script");
script.src = url;
script.async = true;
script.onload = () => resolve();
@ -142,7 +224,7 @@ const performLoginRequest = async () => {
const res = await login({
account: account.value,
password: password.value,
code: verifyCode.value
code: verifyCode.value,
});
if (res && res.code === 200) {
@ -199,7 +281,10 @@ const handleSendCode = async () => {
}
codeSending.value = true;
try {
const res = await sendLoginCode({ account: account.value, channel: verifyType.value });
const res = await sendLoginCode({
account: account.value,
channel: verifyType.value,
});
if (res?.code === 200) {
ElMessage.success("验证码已发送");
startCodeCountdown();
@ -243,12 +328,14 @@ const startGeetest4 = async () => {
}
// @ts-ignore
window.initGeetest4({
window.initGeetest4(
{
captchaId: config.captcha_id,
product: 'bind',
language: 'zh-CN',
container: captchaContainer.value
}, (instance) => {
product: "bind",
language: "zh-CN",
container: captchaContainer.value,
},
(instance) => {
captchaInstance.value = instance;
//
@ -262,7 +349,7 @@ const startGeetest4 = async () => {
lot_number: result?.lot_number || "",
pass_token: result?.pass_token || "",
gen_time: result?.gen_time || "",
captcha_output: result?.captcha_output || ""
captcha_output: result?.captcha_output || "",
});
if (loginRes && loginRes.code === 200) {
@ -307,7 +394,8 @@ const startGeetest4 = async () => {
//
instance.showCaptcha();
});
},
);
return true;
};
@ -339,7 +427,10 @@ const handleLogin = async () => {
verifyType.value = typeItem?.value || "captcha";
}
if (openVerifyEnabled.value && (verifyType.value === "sms" || verifyType.value === "email")) {
if (
openVerifyEnabled.value &&
(verifyType.value === "sms" || verifyType.value === "email")
) {
if (!verifyCode.value.trim()) {
errorMsg.value = "请输入验证码";
return;
@ -365,7 +456,8 @@ const handleLogin = async () => {
await performLoginRequest();
}
} catch (err) {
errorMsg.value = err?.response?.data?.msg || err?.message || "登录失败,请重试";
errorMsg.value =
err?.response?.data?.msg || err?.message || "登录失败,请重试";
} finally {
loading.value = false;
}
@ -376,14 +468,14 @@ const handleRememberMeChange = async () => {
if (rememberMe.value) {
try {
await ElMessageBox.confirm(
'请确认电脑环境是可信的,登录成功后会自动记住密码。',
'安全提示',
"请确认电脑环境是可信的,登录成功后会自动记住密码。",
"安全提示",
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
closeOnClickModal: false
}
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
closeOnClickModal: false,
},
);
} catch {
rememberMe.value = false;
@ -405,16 +497,21 @@ onMounted(() => {
.then((res) => {
if (res?.code === 200 && res?.data?.use_geetest) {
verifyType.value = res.data.use_geetest;
openVerifyEnabled.value = Number(res.data.openVerify_enabled ?? 1) === 1;
openVerifyEnabled.value =
Number(res.data.openVerify_enabled ?? 1) === 1;
} else {
//
return getOpenVerify().then((verifyRes) => {
if (verifyRes?.code === 200 && Array.isArray(verifyRes?.data)) {
const openItem = verifyRes.data.find((i) => i.label === "openVerify");
const openItem = verifyRes.data.find(
(i) => i.label === "openVerify",
);
if (openItem?.value !== undefined) {
openVerifyEnabled.value = String(openItem.value) === "1";
}
const typeItem = verifyRes.data.find((i) => i.label === "verifyType");
const typeItem = verifyRes.data.find(
(i) => i.label === "verifyType",
);
if (typeItem?.value) verifyType.value = typeItem.value;
}
});

View File

@ -279,6 +279,7 @@
<script lang="ts" setup>
import { ref, computed, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { getFileUrl as getFileUrlUtil } from "@/utils/url";
import {
Folder,
Grid,
@ -412,7 +413,7 @@ const getUserCateData = async () => {
//
const getFileUrl = (url: string) => {
return `${import.meta.env.VITE_API_BASE_URL}${url}`;
return getFileUrlUtil(url);
};
//

View File

@ -0,0 +1,288 @@
<template>
<div class="storage-settings">
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="140px"
class="settings-form"
>
<el-card shadow="never" class="settings-card">
<template #header>
<div class="card-header">
<span>存储位置配置</span>
</div>
</template>
<el-form-item label="存储类型" prop="storage_type">
<el-radio-group v-model="formData.storage_type">
<el-radio label="local">本地存储</el-radio>
<el-radio label="qiniu">七牛云存储</el-radio>
</el-radio-group>
</el-form-item>
<template v-if="formData.storage_type === 'qiniu'">
<el-divider content-position="left">七牛云配置</el-divider>
<el-form-item label="AccessKey" prop="qiniu_access_key">
<el-input
v-model="formData.qiniu_access_key"
placeholder="请输入七牛云 AccessKey"
clearable
/>
</el-form-item>
<el-form-item label="SecretKey" prop="qiniu_secret_key">
<el-input
v-model="formData.qiniu_secret_key"
type="password"
placeholder="请输入七牛云 SecretKey"
show-password
clearable
/>
</el-form-item>
<el-form-item label="Bucket" prop="qiniu_bucket">
<el-input
v-model="formData.qiniu_bucket"
placeholder="请输入存储空间名称"
clearable
/>
</el-form-item>
<el-form-item label="CDN域名" prop="qiniu_domain">
<el-input
v-model="formData.qiniu_domain"
placeholder="请输入CDN加速域名https://cdn.example.com"
clearable
>
<template #prepend>
<el-icon><Link /></el-icon>
</template>
</el-input>
<div class="form-tip">用于访问上传的文件需要在七牛云控制台配置</div>
</el-form-item>
<el-form-item label="存储区域" prop="qiniu_region">
<el-select
v-model="formData.qiniu_region"
placeholder="请选择存储区域"
clearable
>
<el-option label="华东-浙江" value="z0" />
<el-option label="华北-河北" value="z1" />
<el-option label="华南-广东" value="z2" />
<el-option label="北美-洛杉矶" value="na0" />
<el-option label="亚太-新加坡" value="as0" />
<el-option label="华东-浙江2" value="cn-east-2" />
</el-select>
<div class="form-tip">请根据您的Bucket所在区域选择</div>
</el-form-item>
</template>
<el-alert
v-if="formData.storage_type === 'local'"
title="本地存储说明"
type="info"
:closable="false"
show-icon
>
文件将存储在服务器本地磁盘适合小规模应用或开发测试环境
</el-alert>
<el-alert
v-if="formData.storage_type === 'qiniu'"
title="七牛云存储说明"
type="warning"
:closable="false"
show-icon
>
<p>使用七牛云存储需要</p>
<ul style="margin: 8px 0; padding-left: 20px;">
<li>在七牛云控制台创建存储空间Bucket</li>
<li>获取 AccessKey SecretKey在个人中心-密钥管理</li>
<li>配置 CDN 加速域名在存储空间-域名管理</li>
<li>确保域名已备案并完成 CNAME 解析</li>
</ul>
</el-alert>
</el-card>
</el-form>
<div class="footer-actions">
<el-button @click="handleReset">重置</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">
保存设置
</el-button>
</div>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from "vue";
import { ElMessage } from "element-plus";
import { Link } from "@element-plus/icons-vue";
import { getStorageConfig, saveStorageConfig } from "@/api/sitesettings";
const STORAGE_KEY = "storage_settings_draft";
const formRef = ref();
const submitting = ref(false);
const formData = reactive({
storage_type: "local",
qiniu_access_key: "",
qiniu_secret_key: "",
qiniu_bucket: "",
qiniu_domain: "",
qiniu_region: "",
});
const validateQiniuField = (field, fieldName) => {
return (_rule, value, callback) => {
if (
formData.storage_type === "qiniu" &&
!String(value || "").trim()
) {
callback(new Error(`${fieldName}不能为空`));
return;
}
callback();
};
};
const rules = {
qiniu_access_key: [
{ validator: validateQiniuField("qiniu_access_key", "AccessKey"), trigger: "blur" },
],
qiniu_secret_key: [
{ validator: validateQiniuField("qiniu_secret_key", "SecretKey"), trigger: "blur" },
],
qiniu_bucket: [
{ validator: validateQiniuField("qiniu_bucket", "Bucket"), trigger: "blur" },
],
qiniu_domain: [
{ validator: validateQiniuField("qiniu_domain", "CDN域名"), trigger: "blur" },
],
};
const loadDraft = () => {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
try {
const data = JSON.parse(raw);
Object.assign(formData, data);
} catch {
// ignore invalid cache
}
};
const loadRemoteConfig = async () => {
try {
const res = await getStorageConfig();
if (res?.code === 200 && res?.data) {
Object.assign(formData, {
storage_type: res.data.storage_type || "local",
qiniu_access_key: res.data.qiniu_access_key || "",
qiniu_secret_key: res.data.qiniu_secret_key || "",
qiniu_bucket: res.data.qiniu_bucket || "",
qiniu_domain: res.data.qiniu_domain || "",
qiniu_region: res.data.qiniu_region || "",
});
}
} catch (error) {
console.error("加载存储配置失败:", error);
}
};
const saveDraft = () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(formData));
};
const handleReset = () => {
Object.assign(formData, {
storage_type: "local",
qiniu_access_key: "",
qiniu_secret_key: "",
qiniu_bucket: "",
qiniu_domain: "",
qiniu_region: "",
});
saveDraft();
ElMessage.success("已重置");
};
const handleSubmit = async () => {
if (!formRef.value) return;
try {
await formRef.value.validate();
} catch {
return;
}
submitting.value = true;
try {
await saveStorageConfig({
storage_type: formData.storage_type,
qiniu_access_key: formData.qiniu_access_key || null,
qiniu_secret_key: formData.qiniu_secret_key || null,
qiniu_bucket: formData.qiniu_bucket || null,
qiniu_domain: formData.qiniu_domain || null,
qiniu_region: formData.qiniu_region || null,
});
saveDraft();
ElMessage.success("保存成功");
} catch (error) {
ElMessage.error(error?.response?.data?.msg || "保存失败");
} finally {
submitting.value = false;
}
};
onMounted(() => {
loadDraft();
loadRemoteConfig();
});
</script>
<style scoped>
.storage-settings {
display: flex;
flex-direction: column;
gap: 16px;
}
.settings-card {
margin-bottom: 12px;
}
.card-header {
font-weight: 600;
font-size: 16px;
}
.form-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
line-height: 1.5;
}
.footer-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
:deep(.el-alert) {
margin-top: 16px;
}
:deep(.el-alert ul) {
list-style-type: disc;
}
:deep(.el-alert li) {
line-height: 1.8;
}
</style>

View File

@ -8,12 +8,21 @@
<div class="settings-container">
<el-tabs v-model="activeTab" class="settings-tabs">
<!-- 验证开关 -->
<el-tab-pane label="验证开关" name="platform">
<platformSettings
ref="platformSettingsRef"
v-if="activeTab === 'platform'"
/>
</el-tab-pane>
<!-- 存储配置 -->
<el-tab-pane label="存储配置" name="storage">
<storageSettings
ref="storageSettingsRef"
v-if="activeTab === 'storage'"
/>
</el-tab-pane>
</el-tabs>
</div>
</div>
@ -22,6 +31,8 @@
<script setup lang="ts">
import { ref } from "vue";
import platformSettings from "./components/platformSettings.vue";
import storageSettings from "./components/storageSettings.vue";
const activeTab = ref("platform");
</script>