11<template >
2- <div class =" image-uploader" >
3- <!-- 预览 -->
4- <div v-if =" imageUrl" class =" mb-3" >
2+ <div class =" w-full" >
3+ <label v-if =" label" class =" block text-xs font-mono font-bold text-gray-500 dark:text-gray-400 uppercase mb-2" >
4+ {{ label }}
5+ </label >
6+
7+ <div v-if =" previewUrl" class =" relative group rounded-xl overflow-hidden border border-gray-200 dark:border-gray-700 shadow-sm aspect-video bg-gray-50 dark:bg-gray-800" >
58 <img
6- :src =" imageUrl"
7- :alt =" label"
8- class =" max-h-48 rounded-lg border shadow-sm object-cover"
9+ :src =" previewUrl"
10+ class =" w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
911 />
10- <button
11- @click =" removeImage"
12- class =" mt-2 text-red-600 text-sm hover:text-red-800"
13- >
14- 删除图片
15- </button >
12+ <div class =" absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center backdrop-blur-sm" >
13+ <button
14+ @click.prevent =" removeImage"
15+ class =" bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-lg text-sm font-bold font-mono transition-colors shadow-lg transform hover:scale-105"
16+ >
17+ DELETE
18+ </button >
19+ </div >
1620 </div >
1721
18- <!-- 上传 -->
1922 <div
20- v-if =" !imageUrl || allowReplace"
21- class =" border-2 border-dashed border-gray-300 rounded-lg p-4 text-center hover:border-blue-500 transition-colors"
22- :class =" { 'cursor-pointer': !uploading }"
23- @click =" !uploading && triggerFileInput()"
23+ v-else
24+ class =" relative border-2 border-dashed rounded-xl p-8 text-center transition-all duration-300"
25+ :class =" [
26+ dragover
27+ ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/10'
28+ : 'border-gray-300 dark:border-gray-700 hover:border-blue-400 dark:hover:border-blue-500 hover:bg-gray-50 dark:hover:bg-gray-800/50'
29+ ]"
2430 @dragover.prevent =" dragover = true"
2531 @dragleave.prevent =" dragover = false"
2632 @drop.prevent =" onDrop"
27- :style = " dropzoneStyle "
33+ @click = " triggerFileInput "
2834 >
2935 <input
3036 type =" file"
31- ref =" fileInput "
37+ ref =" fileInputRef "
3238 @change =" onFileChange"
3339 class =" hidden"
34- accept =" image/* "
40+ accept =" image/png,image/jpeg,image/gif,image/webp "
3541 />
3642
37- <div v-if =" uploading" >
38- <div
39- class =" animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500 mx-auto mb-2"
40- ></div >
41- <p class =" text-gray-600" >上传中...</p >
43+ <div v-if =" uploading" class =" flex flex-col items-center justify-center py-4" >
44+ <svg class =" animate-spin h-8 w-8 text-blue-500 mb-3" xmlns =" http://www.w3.org/2000/svg" fill =" none" viewBox =" 0 0 24 24" >
45+ <circle class =" opacity-25" cx =" 12" cy =" 12" r =" 10" stroke =" currentColor" stroke-width =" 4" ></circle >
46+ <path class =" opacity-75" fill =" currentColor" d =" M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" ></path >
47+ </svg >
48+ <span class =" text-xs font-mono text-blue-500 animate-pulse" >UPLOADING...</span >
4249 </div >
4350
44- <div v-else >
45- <svg
46- xmlns =" http://www.w3.org/2000/svg"
47- class =" h-12 w-12 mx-auto text-gray-400 mb-2"
48- fill =" none"
49- viewBox =" 0 0 24 24"
50- stroke =" currentColor"
51- >
52- <path
53- stroke-linecap =" round"
54- stroke-linejoin =" round"
55- stroke-width =" 2"
56- d =" M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
57- />
51+ <div v-else class =" flex flex-col items-center justify-center cursor-pointer py-2" >
52+ <svg class =" w-10 h-10 text-gray-400 dark:text-gray-500 mb-3" fill =" none" stroke =" currentColor" viewBox =" 0 0 24 24" >
53+ <path stroke-linecap =" round" stroke-linejoin =" round" stroke-width =" 1.5" d =" M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
5854 </svg >
59- <p class =" text-gray-600 mb-1" >
60- {{ label || "点击或拖放图片到此处上传" }}
55+ <p class =" text-sm font-bold text-gray-600 dark:text-gray-300" >
56+ Click or Drag to Upload
57+ </p >
58+ <p class =" text-xs text-gray-400 mt-1 font-mono" >
59+ PNG, JPG, GIF up to 5MB
6160 </p >
62- <p class =" text-gray-500 text-xs" >支持 JPG、PNG、GIF,最大 5 MB</p >
6361 </div >
6462 </div >
6563
66- <!-- 错误 -->
67- <div v-if =" error" class =" mt-2 text-red-600 text-sm" >{{ error }}</div >
64+ <div v-if =" error" class =" mt-2 text-xs font-mono text-red-500 flex items-center" >
65+ <span class =" mr-1" >⚠</span > {{ error }}
66+ </div >
6867 </div >
6968</template >
7069
7170<script setup>
72- import { ref , computed } from " vue" ;
71+ import { ref , watch , onMounted } from " vue" ;
7372import api from " ../api" ;
7473
74+ // Vue 3 标准 v-model 是 'modelValue'
7575const props = defineProps ({
76- value : String , // v-model 绑定到相对路径
76+ modelValue : String ,
7777 label: String ,
78- uploadType: {
79- type: String ,
80- default: " post" , // 'avatar' | 'post'
81- },
82- allowReplace: { type: Boolean , default: true },
83- postId: { type: [Number , String ], default: null },
78+ uploadType: { type: String , default: " image" } // 'image' (generic) | 'avatar'
8479});
8580
86- const emit = defineEmits ([" update:value " , " upload-success" , " upload-error" ]);
81+ const emit = defineEmits ([" update:modelValue " , " upload-success" , " upload-error" ]);
8782
88- // state
89- const imageUrl = ref (" " ); // 直接存绝对 URL,方便预览
83+ const fileInputRef = ref (null );
9084const uploading = ref (false );
91- const error = ref (" " );
92- const fileInput = ref (null );
9385const dragover = ref (false );
86+ const error = ref (" " );
87+ const previewUrl = ref (" " );
88+
89+ // 初始化回显逻辑
90+ onMounted (() => {
91+ if (props .modelValue ) {
92+ // 如果是完整 URL 直接用,如果是相对路径则拼上前缀
93+ const isAbsolute = props .modelValue .startsWith (' http' );
94+ // 注意:这里需要根据你的环境变量调整,如果没有定义 VITE_API_URL,默认用空或后端地址
95+ const baseUrl = import .meta.env.VITE_API_URL || ' ' ;
96+ previewUrl .value = isAbsolute ? props .modelValue : ` ${ baseUrl}${ props .modelValue } ` ;
97+ }
98+ });
9499
95- // 初始值(父组件可能传入已保存的相对路径)
96- if (props .value ) {
97- const base = import .meta.env.VITE_API_URL || window.location.origin;
98- imageUrl .value = props .value .startsWith (" http" )
99- ? props .value
100- : base + props .value ;
101- }
102-
103- // style
104- const dropzoneStyle = computed (() =>
105- dragover .value
106- ? " border-color:#3b82f6;background-color:rgba(59,130,246,.05)"
107- : " "
108- );
100+ // 监听外部 modelValue 变化(例如重置表单时)
101+ watch (() => props .modelValue , (newVal ) => {
102+ if (! newVal) {
103+ previewUrl .value = " " ;
104+ } else if (newVal !== previewUrl .value ) {
105+ // 只有当新值和当前预览不一致时才更新(避免循环)
106+ const isAbsolute = newVal .startsWith (' http' );
107+ const baseUrl = import .meta.env.VITE_API_URL || ' ' ;
108+ previewUrl .value = isAbsolute ? newVal : ` ${ baseUrl}${ newVal} ` ;
109+ }
110+ });
109111
110- // helpers
111- const triggerFileInput = () => fileInput .value .click ();
112+ const triggerFileInput = () => fileInputRef .value .click ();
112113
113114const onFileChange = (e ) => {
114115 const file = e .target .files [0 ];
115- if (file) uploadImage (file);
116- e .target .value = null ; // 允许重复选同一文件
116+ if (file) handleUpload (file);
117+ e .target .value = null ; // Reset input
117118};
118119
119120const onDrop = (e ) => {
120121 dragover .value = false ;
121122 const file = e .dataTransfer .files [0 ];
122- if (! file || ! file .type .startsWith (" image/" ))
123- return (error .value = " 请上传有效图片" );
124- uploadImage (file);
123+ if (file) handleUpload (file);
125124};
126125
127- async function uploadImage (file ) {
128- // 校验
129- if (file .size > 5 * 1024 * 1024 ) return (error .value = " 图片最大 5 MB" );
126+ const handleUpload = async (file ) => {
127+ if (! file .type .startsWith (" image/" )) {
128+ error .value = " File must be an image." ;
129+ return ;
130+ }
131+ if (file .size > 5 * 1024 * 1024 ) {
132+ error .value = " File size exceeds 5MB." ;
133+ return ;
134+ }
135+
130136 error .value = " " ;
131137 uploading .value = true ;
132138
133139 try {
134140 const fd = new FormData ();
135- props . uploadType === " avatar "
136- ? fd . append ( " avatar" , file)
137- : fd .append (" image " , file);
141+ // 根据你的 API 定义调整字段名
142+ const fieldName = props . uploadType === ' avatar' ? ' avatar ' : ' image ' ;
143+ fd .append (fieldName , file);
138144
139145 let res;
140- if (props .uploadType === " avatar" ) {
146+ if (props .uploadType === ' avatar' ) {
141147 res = await api .uploadAvatar (fd);
142- } else if (props .postId ) {
143- res = await api .uploadPostCover (props .postId , fd);
144148 } else {
149+ // 默认使用通用的图片上传,返回 { url, path }
145150 res = await api .uploadPostImage (fd);
146151 }
147152
148- // 后端统一 {url, path}
149- const { url , path } = res;
153+ // 解析返回值:你的 API 返回 { url, path }
154+ // url: 绝对路径 (用于预览)
155+ // path: 相对路径 (用于存库)
156+ const { url , path } = res;
150157
151- imageUrl .value = url; // 绝对 URL → 预览
152- emit (" update:value" , path); // 相对路径 → 存库
158+ // 更新预览
159+ previewUrl .value = url;
160+
161+ // 向父组件更新值 (通常存 path 到数据库比较灵活)
162+ emit (" update:modelValue" , path);
153163 emit (" upload-success" , url);
164+
154165 } catch (err) {
155166 console .error (err);
156- error .value = " 上传失败,请重试 " ;
167+ error .value = " Upload failed. Please try again. " ;
157168 emit (" upload-error" , err);
158169 } finally {
159170 uploading .value = false ;
160171 }
161- }
172+ };
162173
163- function removeImage () {
164- imageUrl .value = " " ;
165- emit (" update:value " , " " );
166- }
167- </script >
174+ const removeImage = () => {
175+ previewUrl .value = " " ;
176+ emit (" update:modelValue " , " " );
177+ };
178+ </script >
0 commit comments