Browse Source

修改 bug

master
王泽彦 8 months ago
parent
commit
fdc41c9f74
  1. 12
      admin/src/app/api/campus.ts
  2. 154
      admin/src/app/views/campus/campus.vue
  3. 167
      admin/src/app/views/lesson_course_teaching/components/unified-edit-dialog.vue
  4. 22
      admin/src/components/direct-upload/index.vue
  5. 28
      admin/src/utils/directUpload.ts
  6. 22
      niucloud/app/adminapi/controller/campus/Campus.php
  7. 2
      niucloud/app/adminapi/route/campus.php
  8. 23
      niucloud/app/service/admin/campus/CampusService.php
  9. 17
      niucloud/app/service/admin/communication_records/CommunicationRecordsService.php
  10. 45
      niucloud/app/service/admin/upload/DirectUploadService.php
  11. 2
      niucloud/app/validate/communication_records/CommunicationRecords.php

12
admin/src/app/api/campus.ts

@ -56,4 +56,16 @@ export function deleteCampus(id: number) {
})
}
/**
*
* @param params
* @returns
*/
export function uploadCampusSeal(params: Record<string, any>) {
return request.post('campus/uploadSeal', params, {
showErrorMessage: true,
showSuccessMessage: true,
})
}
// USER_CODE_END -- campus

154
admin/src/app/views/campus/campus.vue

@ -106,12 +106,15 @@
<el-table-column
:label="t('operation')"
fixed="right"
min-width="120"
min-width="200"
>
<template #default="{ row }">
<el-button type="primary" link @click="editEvent(row)">{{
t('edit')
}}</el-button>
<el-button type="success" link @click="uploadSealEvent(row)">
上传签章
</el-button>
<el-button type="primary" link @click="deleteEvent(row.id)">{{
t('delete')
}}</el-button>
@ -131,6 +134,55 @@
</div>
<edit ref="editCampusDialog" @complete="loadCampusList" />
<!-- 上传签章弹窗 -->
<el-dialog
v-model="sealDialog.visible"
title="上传签章"
width="500px"
:close-on-click-modal="false"
>
<el-form :model="sealDialog.form" label-width="80px">
<el-form-item label="校区名称">
<el-input v-model="sealDialog.form.campus_name" disabled />
</el-form-item>
<el-form-item label="签章图片" required>
<el-upload
ref="uploadRef"
class="upload-demo"
:action="uploadAction"
:headers="uploadHeaders"
:before-upload="beforeUpload"
:on-success="onUploadSuccess"
:on-error="onUploadError"
:file-list="sealDialog.fileList"
list-type="picture-card"
:limit="1"
accept=".jpg,.jpeg,.png,.gif"
>
<el-icon><Plus /></el-icon>
<template #tip>
<div class="el-upload__tip">
只能上传jpg/png/gif文件且不超过2MB
</div>
</template>
</el-upload>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="sealDialog.visible = false">取消</el-button>
<el-button
type="primary"
@click="saveSeal"
:loading="sealDialog.loading"
:disabled="!sealDialog.form.seal_image"
>
确定
</el-button>
</span>
</template>
</el-dialog>
</el-card>
</div>
</template>
@ -139,11 +191,13 @@
import { reactive, ref, watch } from 'vue'
import { t } from '@/lang'
import { useDictionary } from '@/app/api/dict'
import { getCampusList, deleteCampus } from '@/app/api/campus'
import { getCampusList, deleteCampus, uploadCampusSeal } from '@/app/api/campus'
import { img } from '@/utils/common'
import { ElMessageBox, FormInstance } from 'element-plus'
import { ElMessageBox, ElMessage, FormInstance } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import Edit from '@/app/views/campus/components/campus-edit.vue'
import { useRoute } from 'vue-router'
import { getToken } from '@/utils/common'
const route = useRoute()
const pageName = route.meta.title
@ -165,6 +219,25 @@ const searchFormRef = ref<FormInstance>()
//
const selectData = ref<any[]>([])
//
const sealDialog = reactive({
visible: false,
loading: false,
form: {
campus_id: '',
campus_name: '',
seal_image: ''
},
fileList: [] as any[]
})
//
const uploadAction = `${import.meta.env.VITE_APP_BASE_URL}/adminapi/upload/file`
const uploadHeaders = {
token: getToken()
}
const uploadRef = ref()
//
const campus_statusList = ref([] as any[])
const campus_statusDictList = async () => {
@ -238,6 +311,81 @@ const resetForm = (formEl: FormInstance | undefined) => {
formEl.resetFields()
loadCampusList()
}
/**
* 上传签章
*/
const uploadSealEvent = (data: any) => {
sealDialog.form.campus_id = data.id
sealDialog.form.campus_name = data.campus_name
sealDialog.form.seal_image = ''
sealDialog.fileList = []
sealDialog.visible = true
}
/**
* 上传前验证
*/
const beforeUpload = (file: any) => {
const isImage = /\.(jpg|jpeg|png|gif)$/i.test(file.name)
const isLt2M = file.size / 1024 / 1024 < 2
if (!isImage) {
ElMessage.error('只能上传jpg/png/gif格式的图片!')
return false
}
if (!isLt2M) {
ElMessage.error('上传文件大小不能超过 2MB!')
return false
}
return true
}
/**
* 上传成功回调
*/
const onUploadSuccess = (response: any, file: any) => {
if (response.code === 1) {
sealDialog.form.seal_image = response.data.url
ElMessage.success('图片上传成功')
} else {
ElMessage.error(response.msg || '上传失败')
sealDialog.fileList = []
}
}
/**
* 上传失败回调
*/
const onUploadError = (error: any) => {
ElMessage.error('图片上传失败')
sealDialog.fileList = []
}
/**
* 保存签章
*/
const saveSeal = async () => {
if (!sealDialog.form.seal_image) {
ElMessage.warning('请先上传签章图片')
return
}
sealDialog.loading = true
try {
await uploadCampusSeal({
campus_id: sealDialog.form.campus_id,
seal_image: sealDialog.form.seal_image
})
sealDialog.visible = false
loadCampusList() //
} catch (error) {
console.error('签章上传失败:', error)
} finally {
sealDialog.loading = false
}
}
</script>
<style lang="scss" scoped>

167
admin/src/app/views/lesson_course_teaching/components/unified-edit-dialog.vue

@ -27,25 +27,65 @@
<el-row :gutter="20">
<el-col :span="12">
<el-form-item :label="t('image')" prop="image">
<el-form-item :label="getPreviewLabel" prop="image">
<div class="image-preview-container">
<div v-if="formData.image" class="image-display">
<!-- 根据素材类型和是否有URL显示不同内容 -->
<!-- 图片类型显示图片预览 -->
<div v-if="formData.type === '3' && formData.url" class="image-display">
<el-image
:src="img(formData.image)"
:src="formData.url"
style="width: 120px; height: 120px"
fit="cover"
:preview-src-list="[img(formData.image)]"
:preview-src-list="[formData.url]"
/>
<div class="image-info">
<p>封面预览</p>
<el-button type="danger" size="small" @click="clearImage">删除</el-button>
<p>图片预览</p>
<el-button type="danger" size="small" @click="clearFile">删除</el-button>
</div>
</div>
<!-- 视频类型显示视频预览 -->
<div v-else-if="formData.type === '1' && formData.url" class="video-display">
<video
:src="formData.url"
controls
style="width: 120px; height: 120px"
preload="metadata"
>
您的浏览器不支持视频播放
</video>
<div class="image-info">
<p>视频预览</p>
<el-button type="danger" size="small" @click="clearFile">删除</el-button>
</div>
</div>
<!-- 文件类型显示文件图标和下载 -->
<div v-else-if="formData.type === '2' && formData.url" class="file-display">
<div class="file-icon-container" @click="downloadFile">
<el-icon size="40" class="file-icon">
<Document />
</el-icon>
<div class="file-name">{{ getFileName(formData.url) }}</div>
</div>
<div class="image-info">
<el-button type="primary" size="small" @click="downloadFile">
<el-icon><Download /></el-icon>
下载
</el-button>
<el-button type="danger" size="small" @click="clearFile">删除</el-button>
</div>
</div>
<!-- 暂无内容时显示占位符 -->
<div v-else class="image-placeholder">
<el-icon size="30" class="mb-2">
<Picture />
<Picture v-if="!formData.type || formData.type === '3'" />
<VideoPlay v-else-if="formData.type === '1'" />
<Document v-else-if="formData.type === '2'" />
</el-icon>
<div class="text-xs text-gray-400">暂无封面图片</div>
<div class="text-xs text-gray-400">{{ getPlaceholderText }}</div>
</div>
</div>
</el-form-item>
@ -64,7 +104,7 @@
</el-col>
</el-row>
<!-- 根据素材类型显示不同的上传和预览组件 -->
<!-- 文件上传区域 -->
<el-row :gutter="20" v-if="formData.type">
<el-col :span="24">
<el-form-item :label="materialLabel" prop="url">
@ -85,54 +125,6 @@
</el-button>
</direct-upload>
</div>
<!-- 文件预览区域 -->
<div class="material-preview-area" v-if="formData.url">
<!-- 图片预览 -->
<div v-if="formData.type == '3'" class="image-preview">
<el-image
:src="formData.url"
style="width: 200px; height: 120px"
fit="cover"
:preview-src-list="[formData.url]"
/>
<div class="preview-info">
<p>图片预览</p>
<el-button type="danger" size="small" @click="clearFile">删除</el-button>
</div>
</div>
<!-- 视频预览 -->
<div v-else-if="formData.type == '1'" class="video-preview">
<video
:src="formData.url"
controls
style="width: 300px; height: 200px"
preload="metadata"
>
您的浏览器不支持视频播放
</video>
<div class="preview-info">
<p>视频预览</p>
<el-button type="danger" size="small" @click="clearFile">删除</el-button>
</div>
</div>
<!-- 文件预览 -->
<div v-else-if="formData.type == '2'" class="file-preview">
<div class="file-info">
<el-icon><Document /></el-icon>
<span>{{ getFileName(formData.url) }}</span>
</div>
<div class="file-actions">
<el-button type="primary" size="small" @click="downloadFile">
<el-icon><Download /></el-icon>
下载
</el-button>
<el-button type="danger" size="small" @click="clearFile">删除</el-button>
</div>
</div>
</div>
</el-form-item>
</el-col>
</el-row>
@ -206,7 +198,7 @@ import { addUnified, editUnified, getUnifiedInfo } from '@/app/api/lesson_course
import { getWithPersonnelDataList } from '@/app/api/lesson_course_teaching'
import { img } from '@/utils/common'
import { ElMessage, FormInstance } from 'element-plus'
import { Plus, Document, Download, Picture } from '@element-plus/icons-vue'
import { Plus, Document, Download, Picture, VideoPlay } from '@element-plus/icons-vue'
import { getToken } from '@/utils/common'
import type { UploadProps } from 'element-plus'
import Editor from '@/components/editor/index.vue'
@ -260,6 +252,26 @@ const dialogTitle = computed(() => {
return `${action}${props.moduleConfig?.name || ''}`
})
//
const getPreviewLabel = computed(() => {
switch (formData.type) {
case '1': return '视频预览'
case '2': return '文件预览'
case '3': return '图片预览'
default: return '封面预览'
}
})
//
const getPlaceholderText = computed(() => {
switch (formData.type) {
case '1': return '暂无视频文件'
case '2': return '暂无文档文件'
case '3': return '暂无图片文件'
default: return '暂无封面图片'
}
})
//
const getFileAccept = computed(() => {
@ -573,7 +585,7 @@ defineExpose({
}
.image-preview-container {
.image-display {
.image-display, .video-display, .file-display {
display: flex;
align-items: flex-start;
gap: 15px;
@ -591,6 +603,43 @@ defineExpose({
}
}
.file-display {
.file-icon-container {
width: 120px;
height: 120px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 1px solid #dcdfe6;
border-radius: 4px;
background-color: #fafafa;
cursor: pointer;
transition: all 0.3s;
&:hover {
border-color: #409eff;
background-color: #ecf5ff;
}
.file-icon {
color: #606266;
margin-bottom: 8px;
}
.file-name {
font-size: 12px;
color: #909399;
text-align: center;
word-break: break-word;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.image-placeholder {
width: 120px;
height: 120px;

22
admin/src/components/direct-upload/index.vue

@ -23,7 +23,7 @@
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import type { UploadFile, UploadProps } from 'element-plus'
import { uploadFile, isDirectUploadSupported } from '@/utils/directUpload'
import { uploadFile } from '@/utils/directUpload'
import { getToken } from '@/utils/common'
interface Props {
@ -116,16 +116,8 @@ const handleFileChange = async (uploadFile: UploadFile) => {
uploadProgress.value = 0
try {
//
const supportDirectUpload = await isDirectUploadSupported()
if (supportDirectUpload) {
// 使
//
await handleDirectUpload(uploadFile.raw, uploadFile)
} else {
//
await handleFallbackUpload(uploadFile.raw, uploadFile)
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : '上传失败'
ElMessage.error(errorMsg)
@ -140,6 +132,7 @@ const handleFileChange = async (uploadFile: UploadFile) => {
* 直传上传
*/
const handleDirectUpload = async (file: File, uploadFileObj: UploadFile) => {
try {
const result = await uploadFile({
file,
fileType: props.fileType,
@ -155,6 +148,15 @@ const handleDirectUpload = async (file: File, uploadFileObj: UploadFile) => {
} else {
throw new Error(result.error || '上传失败')
}
} catch (error) {
//
if (props.fallbackUrl) {
console.warn('Direct upload failed, trying fallback upload:', error)
await handleFallbackUpload(file, uploadFileObj)
} else {
throw error
}
}
}
/**

28
admin/src/utils/directUpload.ts

@ -11,6 +11,7 @@ export interface UploadCredentials {
upload_url: string
credentials: any
file_path: string
key?: string // 上传到COS的文件路径
domain: string
max_size: number
expired: number
@ -140,7 +141,7 @@ export class DirectUpload {
}
/**
* COS直接上传
* COS直接上传 - POST表单直传方式
*/
private async directUploadToTencent(
file: File,
@ -151,13 +152,18 @@ export class DirectUpload {
return new Promise((resolve, reject) => {
const formData = new FormData()
// 添加腾讯云COS必需的字段
formData.append('key', filePath)
// 使用后端返回的key,如果没有则使用filePath替换${filename}
const finalKey = credentials.key || credentials.credentials.key || filePath.replace('${filename}', file.name)
// 腾讯云COS POST表单直传必需字段,严格按照官方文档顺序
formData.append('key', finalKey)
formData.append('policy', credentials.credentials.policy)
formData.append('q-sign-algorithm', credentials.credentials['q-sign-algorithm'])
formData.append('q-ak', credentials.credentials['q-ak'])
formData.append('q-key-time', credentials.credentials['q-key-time'])
formData.append('q-sign-time', credentials.credentials['q-sign-time']) // 必须包含此字段
formData.append('q-signature', credentials.credentials['q-signature'])
// file字段必须在最后
formData.append('file', file)
const xhr = new XMLHttpRequest()
@ -175,13 +181,14 @@ export class DirectUpload {
// 上传完成
xhr.addEventListener('load', () => {
if (xhr.status === 204 || xhr.status === 200) {
const fileUrl = `${credentials.domain}/${filePath}`
const fileUrl = `${credentials.domain}/${finalKey}`
resolve({
success: true,
url: fileUrl
})
} else {
reject(new Error('上传失败,请重试'))
console.error('Upload failed:', xhr.status, xhr.responseText)
reject(new Error(`上传失败,状态码: ${xhr.status}`))
}
})
@ -215,6 +222,7 @@ export class DirectUpload {
formData.append('q-sign-algorithm', credentials.credentials['q-sign-algorithm'])
formData.append('q-ak', credentials.credentials['q-ak'])
formData.append('q-key-time', credentials.credentials['q-key-time'])
formData.append('q-sign-time', credentials.credentials['q-sign-time']) // 必须包含此字段
formData.append('q-signature', credentials.credentials['q-signature'])
formData.append('domain', credentials.domain)
formData.append('file', file)
@ -330,14 +338,10 @@ export async function uploadFile(options: UploadOptions): Promise<UploadResult>
/**
*
*
*/
export async function isDirectUploadSupported(): Promise<boolean> {
try {
const directUploadInstance = new DirectUpload()
await directUploadInstance.getUploadCredentials('image')
// 简单返回true,让实际上传时处理错误
// 避免额外的网络请求
return true
} catch (error) {
console.warn('Direct upload not supported:', error)
return false
}
}

22
niucloud/app/adminapi/controller/campus/Campus.php

@ -92,5 +92,27 @@ class Campus extends BaseAdminController
return (new CampusService())->del($id);
}
/**
* 上传校区签章
* @return \think\Response
*/
public function uploadSeal(){
$data = $this->request->params([
["campus_id", 0],
["seal_image", ""],
]);
if (empty($data['campus_id'])) {
return error('校区ID不能为空');
}
if (empty($data['seal_image'])) {
return error('签章图片不能为空');
}
(new CampusService())->uploadSeal($data['campus_id'], $data['seal_image']);
return success('签章上传成功');
}
}

2
niucloud/app/adminapi/route/campus.php

@ -28,6 +28,8 @@ Route::group('campus', function () {
Route::put('campus/:id', 'campus.Campus/edit');
//删除校区
Route::delete('campus/:id', 'campus.Campus/del');
//上传校区签章
Route::post('uploadSeal', 'campus.Campus/uploadSeal');
})->middleware([
AdminCheckToken::class,

23
niucloud/app/service/admin/campus/CampusService.php

@ -107,5 +107,28 @@ class CampusService extends BaseAdminService
return success('DELETE_SUCCESS');
}
/**
* 上传校区签章
* @param int $campus_id
* @param string $seal_image
* @return bool
*/
public function uploadSeal(int $campus_id, string $seal_image)
{
// 检查校区是否存在
$campus = $this->model->where([['id', '=', $campus_id]])->find();
if (!$campus) {
throw new \Exception('校区不存在');
}
// 更新校区签章
$this->model->where([['id', '=', $campus_id]])->update([
'seal_image' => $seal_image,
'update_time' => time()
]);
return true;
}
}

17
niucloud/app/service/admin/communication_records/CommunicationRecordsService.php

@ -114,12 +114,23 @@ class CommunicationRecordsService extends BaseAdminService
*/
public function edit(int $id, array $data)
{
// 检查记录是否存在
$record = $this->model->where([['id', '=', $id]])->find();
if (!$record) {
return fail("记录不存在");
}
// 获取当前用户对应的员工ID
$personnel = new Personnel();
$data['staff_id'] = $personnel->where(['sys_user_id' => $this->uid])->value("id");
$staff_id = $personnel->where(['sys_user_id' => $this->uid])->value("id");
if(!$data['staff_id']){
return fail("操作失败");
// 如果是管理员(没有对应的personnel记录),保持原有的staff_id不变
// 如果是普通员工,则更新为当前员工ID
if ($staff_id) {
$data['staff_id'] = $staff_id;
}
// 如果staff_id为空且是管理员,不更新staff_id字段,保持原值
$this->model->where([['id', '=', $id]])->update($data);
return success("操作成功");
}

45
niucloud/app/service/admin/upload/DirectUploadService.php

@ -85,20 +85,23 @@ class DirectUploadService extends BaseAdminService
}
}
// 使用UTC时间戳,确保与腾讯云服务器时间同步
$current_time = time();
$expired = $current_time + 3600; // 1小时过期
// 腾讯云COS要求:q-key-time和q-sign-time使用相同的时间范围
$key_time = $current_time . ';' . $expired;
$sign_time = $key_time; // POST上传时,sign-time通常与key-time相同
// 生成POST策略 (腾讯云COS对象格式)
// 腾讯云COS POST表单策略 - 必须包含所有签名相关字段
$policy = [
'expiration' => gmdate('Y-m-d\TH:i:s.000\Z', $expired),
'conditions' => [
// key路径限制 - starts-with格式
['starts-with', '$key', dirname($file_path) . '/'],
// 签名字段 - 对象格式 (腾讯云COS要求)
['$q-sign-algorithm' => 'sha1'],
['$q-ak' => $config['access_key']],
['$q-key-time' => $key_time]
['eq', '$q-sign-algorithm', 'sha1'],
['eq', '$q-ak', $config['access_key']],
['eq', '$q-key-time', $key_time],
['eq', '$q-sign-time', $sign_time] // 必需字段
]
];
@ -108,27 +111,47 @@ class DirectUploadService extends BaseAdminService
$policy['conditions'][] = ['content-length-range', 0, $max_size];
}
$policy_encoded = base64_encode(json_encode($policy, JSON_UNESCAPED_SLASHES));
// 重要:生成policy字符串时使用标准JSON格式
$policy_string = json_encode($policy, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$policy_base64 = base64_encode($policy_string);
// 腾讯云COS POST签名算法
// 腾讯云COS POST签名计算 - 根据官方文档v4签名算法
// 1. 生成 SignKey = HMAC-SHA1(SecretKey, KeyTime)
$sign_key = hash_hmac('sha1', $key_time, $config['secret_key']);
$string_to_sign = $policy_encoded;
// 2. 生成 StringToSign = SHA1(policy) - 注意:是policy字符串的SHA1,不是Base64
$string_to_sign = sha1($policy_string);
// 3. 生成 Signature = HMAC-SHA1(SignKey, StringToSign)
$signature = hash_hmac('sha1', $string_to_sign, $sign_key);
return [
'storage_type' => 'tencent',
'upload_url' => "https://{$config['bucket']}.cos.{$config['region']}.myqcloud.com",
'credentials' => [
'policy' => $policy_encoded,
'policy' => $policy_base64,
'q-sign-algorithm' => 'sha1',
'q-ak' => $config['access_key'],
'q-key-time' => $key_time,
'q-sign-time' => $sign_time,
'q-signature' => $signature,
'key' => $file_path,
],
'file_path' => $file_path,
'key' => $file_path,
'domain' => $config['domain'] ?? "https://{$config['bucket']}.cos.{$config['region']}.myqcloud.com",
'max_size' => $max_size,
'expired' => $expired
'expired' => $expired,
// 调试信息(生产环境应移除)
'debug' => [
'policy_string' => $policy_string,
'string_to_sign' => $string_to_sign,
'sign_key' => $sign_key,
'current_time_utc' => gmdate('Y-m-d H:i:s', $current_time) . ' UTC',
'expired_time_utc' => gmdate('Y-m-d H:i:s', $expired) . ' UTC',
'timestamp_current' => $current_time,
'timestamp_expired' => $expired
]
];
}

2
niucloud/app/validate/communication_records/CommunicationRecords.php

@ -23,7 +23,7 @@ class CommunicationRecords extends BaseValidate
'resource_id' => 'require',
'resource_type' => 'require',
'communication_type' => 'require',
'communication_result' => 'require',
// 'communication_result' => 'require',
'communication_time' => 'require',
];

Loading…
Cancel
Save