Initial commit

This commit is contained in:
admin
2025-12-12 14:06:35 +08:00
parent 61c61a77a2
commit c7af3b3e44
30 changed files with 271 additions and 44 deletions

View File

@@ -30,3 +30,12 @@ export function clearFiles() {
method: 'delete'
})
}
export function replaceUrl(data) {
return request({
url: '/file/replace_url/',
method: 'post',
data
})
}

View File

@@ -20,7 +20,7 @@
<template slot-scope="{row}"><span>{{ row.teacher_name }}</span></template>
</el-table-column>
<el-table-column label="封面" width="80px" align="center">
<template slot-scope="{row}"><img :src="row.image" width="50" height="50" style="object-fit:cover"></template>
<template slot-scope="{row}"><img :src="resolveUrl(row.image)" width="50" height="50" style="object-fit:cover"></template>
</el-table-column>
<el-table-column label="学习人数" width="80px" align="center">
<template slot-scope="{row}"><span>{{ row.students }}</span></template>
@@ -92,7 +92,7 @@
:show-file-list="false"
:on-success="handleAvatarSuccess"
:headers="upHeaders">
<img v-if="temp.image" :src="temp.image" class="avatar">
<img v-if="temp.image" :src="resolveUrl(temp.image)" class="avatar">
<i v-else class="el-icon-plus avatar-uploader-icon"></i>
</el-upload>
</el-form-item>
@@ -362,6 +362,16 @@ export default {
detail: ''
}
},
resolveUrl(url) {
if (!url) return ''
if (url.startsWith('http://localhost:8000/media/')) {
return url.replace('http://localhost:8000', '')
}
if (url.startsWith('http://127.0.0.1:8000/media/')) {
return url.replace('http://127.0.0.1:8000', '')
}
return url
},
handleCreate() {
this.resetTemp()
this.dialogStatus = 'create'

View File

@@ -44,6 +44,13 @@
@click="handleClear"
size="small"
>一键清空</el-button>
<el-button
class="filter-item"
type="warning"
icon="el-icon-edit"
@click="handleReplaceUrl"
size="small"
>一键修改IP/URL</el-button>
</div>
<el-table
v-loading="listLoading"
@@ -86,6 +93,21 @@
</el-table-column>
</el-table>
<el-dialog title="一键修改IP/URL" :visible.sync="dialogFormVisible" width="30%">
<el-form :model="temp" label-position="left" label-width="100px" style="width: 400px; margin-left:50px;">
<el-form-item label="原IP/URL" prop="old_url">
<el-input v-model="temp.old_url" placeholder="请输入原IP/URL" />
</el-form-item>
<el-form-item label="新IP/URL" prop="new_url">
<el-input v-model="temp.new_url" placeholder="请输入新IP/URL" />
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="dialogFormVisible = false">取消</el-button>
<el-button type="primary" @click="replaceUrlData">确定</el-button>
</div>
</el-dialog>
<pagination
v-show="fileList.count>0"
:total="fileList.count"
@@ -96,7 +118,7 @@
</div>
</template>
<script>
import { getFileList, deleteFile, clearFiles } from "@/api/file"
import { getFileList, deleteFile, clearFiles, replaceUrl } from "@/api/file"
import Pagination from "@/components/Pagination"
export default {
components: { Pagination },
@@ -108,6 +130,11 @@ export default {
page: 1,
page_size: 20
},
dialogFormVisible: false,
temp: {
old_url: '',
new_url: ''
},
enabledOptions: [
{ key: "文档", display_name: "文档" },
{ key: "图片", display_name: "图片" },
@@ -175,6 +202,35 @@ export default {
this.getList()
})
})
},
handleReplaceUrl() {
this.temp = {
old_url: '',
new_url: ''
}
this.dialogFormVisible = true
},
replaceUrlData() {
if (!this.temp.old_url || !this.temp.new_url) {
this.$message.error('请填写完整')
return
}
this.$confirm('确认修改? 此操作不可恢复!', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
replaceUrl(this.temp).then(response => {
this.$notify({
title: 'Success',
message: response.data.message || '修改成功',
type: 'success',
duration: 2000
})
this.dialogFormVisible = false
this.getList()
})
})
}
}
};

View File

@@ -41,7 +41,11 @@ module.exports = {
proxy: {
'/api': {
// target: 'http://localhost:8000',
target: process.env.PROXY_TARGET || 'http://192.168.5.95:8000',
target: process.env.PROXY_TARGET || 'http://192.168.5.112:8000',
changeOrigin: true
},
'/media': {
target: process.env.PROXY_TARGET || 'http://192.168.5.112:8000',
changeOrigin: true
}
},

View File

@@ -1,6 +1,7 @@
import os
import shutil
import random
import socket
from django.core.management.base import BaseCommand
from django.conf import settings
from apps.crm.models import Project, StudentShowcase, Teacher
@@ -9,7 +10,18 @@ class Command(BaseCommand):
help = 'Populate database with business mock data using existing local images'
def handle(self, *args, **kwargs):
self.base_url = "http://192.168.5.95:8000"
# Get local IP
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(('8.8.8.8', 80))
ip = s.getsockname()[0]
s.close()
except Exception:
ip = '127.0.0.1'
self.base_url = f"http://{ip}:8000"
self.stdout.write(f"Using Base URL: {self.base_url}")
self.media_root = settings.MEDIA_ROOT
self.projects_dir = os.path.join(self.media_root, 'projects')
self.showcases_dir = os.path.join(self.media_root, 'showcases')
@@ -108,14 +120,12 @@ class Command(BaseCommand):
src_img_name = source_project_images[i % len(source_project_images)]
local_path = self.copy_image('projects', src_img_name, 'projects', p_data['target_name'])
full_url = f"{self.base_url}{local_path}"
Project.objects.update_or_create(
title=p_data['title'],
defaults={
'project_type': p_data['project_type'],
'teacher': p_data['teacher'],
'image': full_url,
'image': local_path,
'detail': p_data['detail']
}
)
@@ -149,12 +159,10 @@ class Command(BaseCommand):
# Wait, LS showed showcase_1.jpg in 'projects' folder!
local_path = self.copy_image('projects', src_img_name, 'showcases', v_data['target_name'])
full_url = f"{self.base_url}{local_path}"
StudentShowcase.objects.update_or_create(
title=v_data['title'],
defaults={
'cover_image': full_url,
'cover_image': local_path,
'video_url': v_data['video_url'],
'description': v_data['description']
}

View File

@@ -0,0 +1,43 @@
# Generated by Django 3.2.23 on 2025-12-12 03:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('crm', '0031_student_city'),
]
operations = [
migrations.AlterField(
model_name='banner',
name='image',
field=models.CharField(max_length=500, verbose_name='图片URL'),
),
migrations.AlterField(
model_name='project',
name='image',
field=models.CharField(default='https://images.unsplash.com/photo-1526379095098-d400fd0bf935', max_length=500, verbose_name='封面图片URL'),
),
migrations.AlterField(
model_name='student',
name='avatar',
field=models.CharField(default='https://images.unsplash.com/photo-1535713875002-d1d0cf377fde', max_length=500, verbose_name='微信头像'),
),
migrations.AlterField(
model_name='studenthonor',
name='image',
field=models.CharField(default='https://images.unsplash.com/photo-1579548122080-c35fd6820ecb', max_length=500, verbose_name='证书图片URL'),
),
migrations.AlterField(
model_name='studentshowcase',
name='cover_image',
field=models.CharField(max_length=500, verbose_name='封面图片URL'),
),
migrations.AlterField(
model_name='studentshowcase',
name='video_url',
field=models.CharField(blank=True, max_length=500, null=True, verbose_name='视频链接URL'),
),
]

View File

@@ -49,7 +49,7 @@ class Project(models.Model):
# category = models.ForeignKey(Category, on_delete=models.CASCADE, verbose_name="所属分类", related_name="projects")
teacher = models.ForeignKey(Teacher, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="教学中心", related_name="projects")
custom_teacher = models.CharField(max_length=100, verbose_name="自定义教学中心", default="", blank=True)
image = models.URLField(verbose_name="封面图片URL", default="https://images.unsplash.com/photo-1526379095098-d400fd0bf935")
image = models.CharField(max_length=500, verbose_name="封面图片URL", default="https://images.unsplash.com/photo-1526379095098-d400fd0bf935")
detail = models.TextField(verbose_name="项目详情", blank=True, default="")
students = models.IntegerField(default=0, verbose_name="学习人数")
address = models.CharField(max_length=200, verbose_name="地址", default="", blank=True)
@@ -106,7 +106,7 @@ class Coupon(models.Model):
return self.title
class Banner(models.Model):
image = models.URLField(verbose_name="图片URL")
image = models.CharField(max_length=500, verbose_name="图片URL")
project = models.ForeignKey(Project, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="关联项目")
link = models.CharField(max_length=200, blank=True, null=True, verbose_name="跳转链接")
sort_order = models.IntegerField(default=0, verbose_name="排序")
@@ -134,7 +134,7 @@ class Student(models.Model):
address = models.CharField(max_length=200, verbose_name="地址", null=True, blank=True)
city = models.CharField(max_length=50, verbose_name="城市", null=True, blank=True)
# 已经有avatar字段对应微信头像
avatar = models.URLField(verbose_name="微信头像", default="https://images.unsplash.com/photo-1535713875002-d1d0cf377fde")
avatar = models.CharField(max_length=500, verbose_name="微信头像", default="https://images.unsplash.com/photo-1535713875002-d1d0cf377fde")
is_active = models.BooleanField(default=True, verbose_name="是否活跃")
enrolled_projects = models.ManyToManyField(Project, through='StudentProject', related_name='enrolled_students', verbose_name="已报名项目", blank=True)
@@ -255,7 +255,7 @@ def update_student_learning_count_on_delete(sender, instance, **kwargs):
class StudentHonor(models.Model):
student = models.ForeignKey(Student, on_delete=models.CASCADE, related_name="honors", verbose_name="学员")
title = models.CharField(max_length=100, verbose_name="荣誉标题")
image = models.URLField(verbose_name="证书图片URL", default="https://images.unsplash.com/photo-1579548122080-c35fd6820ecb")
image = models.CharField(max_length=500, verbose_name="证书图片URL", default="https://images.unsplash.com/photo-1579548122080-c35fd6820ecb")
date = models.DateField(verbose_name="获得日期")
description = models.TextField(verbose_name="荣誉描述", blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
@@ -270,8 +270,8 @@ class StudentHonor(models.Model):
class StudentShowcase(models.Model):
title = models.CharField(max_length=100, verbose_name="标题")
cover_image = models.URLField(verbose_name="封面图片URL")
video_url = models.URLField(verbose_name="视频链接URL", blank=True, null=True)
cover_image = models.CharField(max_length=500, verbose_name="封面图片URL")
video_url = models.CharField(max_length=500, verbose_name="视频链接URL", blank=True, null=True)
description = models.TextField(verbose_name="描述", blank=True, default="")
student = models.ForeignKey(Student, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="关联学员", related_name="showcases")
sort_order = models.IntegerField(default=0, verbose_name="排序")

View File

@@ -1,6 +1,25 @@
from rest_framework import serializers
from .models import Category, Teacher, TeachingCenter, Project, Coupon, Banner, Student, StudentCoupon, StudentProject, StudentHonor, StudentShowcase, Notification, NotificationBatch
class AbsoluteURLField(serializers.CharField):
def to_representation(self, value):
if not value:
return value
if value.startswith(('http://', 'https://')):
return value
request = self.context.get('request')
if request:
return request.build_absolute_uri(value)
return value
def to_internal_value(self, data):
if data and isinstance(data, str) and data.startswith(('http://', 'https://')):
# If it's an absolute URL, try to extract the relative path
# We assume standard media URL structure '/media/'
if '/media/' in data:
return '/media/' + data.split('/media/', 1)[1]
return super().to_internal_value(data)
class CategorySerializer(serializers.ModelSerializer):
class Meta:
model = Category
@@ -19,13 +38,24 @@ class TeachingCenterSerializer(serializers.ModelSerializer):
class ProjectSerializer(serializers.ModelSerializer):
# category_name = serializers.CharField(source='category.name', read_only=True)
# category_color = serializers.CharField(source='category.color', read_only=True)
category_name = serializers.CharField(source='get_project_type_display', read_only=True)
category_color = serializers.SerializerMethodField()
teacher_name = serializers.SerializerMethodField()
project_type_display = serializers.CharField(source='get_project_type_display', read_only=True)
image = AbsoluteURLField()
class Meta:
model = Project
fields = '__all__'
def get_category_color(self, obj):
colors = {
'training': 'bg-cyan-100 text-cyan-600',
'competition': 'bg-purple-100 text-purple-600',
'grading': 'bg-blue-100 text-blue-600'
}
return colors.get(obj.project_type, 'bg-gray-100 text-gray-600')
def get_teacher_name(self, obj):
if obj.teacher:
return obj.teacher.name
@@ -62,6 +92,7 @@ class StudentCouponSerializer(serializers.ModelSerializer):
class BannerSerializer(serializers.ModelSerializer):
project_title = serializers.CharField(source='project.title', read_only=True)
image = AbsoluteURLField()
class Meta:
model = Banner
fields = '__all__'
@@ -74,6 +105,7 @@ class StudentSerializer(serializers.ModelSerializer):
coupons = serializers.PrimaryKeyRelatedField(many=True, read_only=True, source='student_coupons')
status_display = serializers.CharField(source='get_status_display', read_only=True)
teacher = serializers.PrimaryKeyRelatedField(source='responsible_teacher', queryset=Teacher.objects.all(), required=False, allow_null=True)
avatar = AbsoluteURLField()
class Meta:
model = Student
@@ -102,7 +134,7 @@ class StudentProjectSerializer(serializers.ModelSerializer):
student_name = serializers.CharField(source='student.name', read_only=True)
student_phone = serializers.CharField(source='student.phone', read_only=True)
project_title = serializers.CharField(source='project.title', read_only=True)
project_image = serializers.CharField(source='project.image', read_only=True)
project_image = AbsoluteURLField(source='project.image', read_only=True)
project_type = serializers.CharField(source='project.project_type', read_only=True)
project_type_display = serializers.CharField(source='project.get_project_type_display', read_only=True)
@@ -113,10 +145,11 @@ class StudentProjectSerializer(serializers.ModelSerializer):
class StudentHonorSerializer(serializers.ModelSerializer):
student_name = serializers.CharField(source='student.name', read_only=True)
student_phone = serializers.CharField(source='student.phone', read_only=True)
student_avatar = serializers.CharField(source='student.avatar', read_only=True)
student_avatar = AbsoluteURLField(source='student.avatar', read_only=True)
student_openid = serializers.CharField(source='student.openid', read_only=True)
student_teaching_center = serializers.CharField(source='student.teacher.name', read_only=True)
student_company_name = serializers.CharField(source='student.company_name', read_only=True)
image = AbsoluteURLField()
class Meta:
model = StudentHonor
@@ -124,6 +157,8 @@ class StudentHonorSerializer(serializers.ModelSerializer):
class StudentShowcaseSerializer(serializers.ModelSerializer):
student_name = serializers.CharField(source='student.name', read_only=True)
cover_image = AbsoluteURLField()
video_url = AbsoluteURLField()
class Meta:
model = StudentShowcase

View File

@@ -212,7 +212,7 @@ class DashboardStatsView(APIView):
pie_chart_data.append({
'type': type_code,
'name': type_mapping.get(type_code, type_code),
'value': item['total_students'],
'value': item['total_students'] or 0,
'itemStyle': { 'color': color }
})
@@ -344,6 +344,24 @@ class CategoryViewSet(viewsets.ModelViewSet):
pagination_class = None # Return all categories without pagination for the app
search_fields = ['name']
def list(self, request, *args, **kwargs):
# Instead of database categories, return Project.PROJECT_TYPE_CHOICES
data = []
# Define some default colors if needed, or mapped by type
colors = {
'training': 'bg-cyan-100 text-cyan-600',
'competition': 'bg-purple-100 text-purple-600',
'grading': 'bg-blue-100 text-blue-600'
}
for code, name in Project.PROJECT_TYPE_CHOICES:
data.append({
'id': code,
'name': name,
'color': colors.get(code, 'bg-gray-100 text-gray-600')
})
return Response(data)
class TeacherViewSet(viewsets.ModelViewSet):
queryset = Teacher.objects.all()
serializer_class = TeacherSerializer

View File

@@ -54,6 +54,14 @@ class FileSerializer(serializers.ModelSerializer):
model = File
fields = "__all__"
def to_representation(self, instance):
ret = super().to_representation(instance)
request = self.context.get('request')
if instance.path and request:
if not instance.path.startswith(('http://', 'https://')):
ret['path'] = request.build_absolute_uri(instance.path)
return ret
class DictTypeSerializer(serializers.ModelSerializer):
"""
数据字典类型序列化

View File

@@ -424,26 +424,8 @@ class FileViewSet(CreateModelMixin, DestroyModelMixin, RetrieveModelMixin, ListM
# Ensure forward slashes for URL
file_name = instance.file.name.replace('\\', '/')
# 开发阶段本机上传的视频或图片用本机IP保存
if settings.DEBUG and (type == '视频' or type == '图片'):
try:
# 获取本机IP
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(('8.8.8.8', 80))
ip = s.getsockname()[0]
s.close()
# 获取端口
host = self.request.get_host()
port = host.split(':')[1] if ':' in host else '80'
# 构建URL
instance.path = f"{self.request.scheme}://{ip}:{port}{settings.MEDIA_URL}{file_name}"
except Exception as e:
logger.error(f"获取本机IP失败: {e}")
instance.path = self.request.build_absolute_uri(settings.MEDIA_URL + file_name)
else:
instance.path = self.request.build_absolute_uri(settings.MEDIA_URL + file_name)
# Save relative path
instance.path = settings.MEDIA_URL + file_name
logger.info(f"File uploaded: {instance.path}")
instance.save()
@@ -459,3 +441,21 @@ class FileViewSet(CreateModelMixin, DestroyModelMixin, RetrieveModelMixin, ListM
file_obj.file.delete(save=False)
file_obj.delete(soft=False)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(methods=['post'], detail=False)
def replace_url(self, request):
old_url = request.data.get('old_url')
new_url = request.data.get('new_url')
if not old_url or not new_url:
return Response({'error': 'Please provide both old_url and new_url'}, status=status.HTTP_400_BAD_REQUEST)
queryset = self.get_queryset()
count = 0
for file_obj in queryset:
if file_obj.path and old_url in file_obj.path:
file_obj.path = file_obj.path.replace(old_url, new_url)
file_obj.save()
count += 1
return Response({'message': f'Updated {count} files'}, status=status.HTTP_200_OK)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

29
project.config.json Normal file
View File

@@ -0,0 +1,29 @@
{
"appid": "wx2d9b9759137ef46b",
"compileType": "miniprogram",
"libVersion": "3.12.1",
"packOptions": {
"ignore": [],
"include": []
},
"setting": {
"coverView": true,
"es6": true,
"postcss": true,
"minified": true,
"enhance": true,
"showShadowRootInWxmlPanel": true,
"packNpmRelationList": [],
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
},
"condition": false
},
"condition": {},
"editorSetting": {
"tabIndent": "insertSpaces",
"tabSize": 2
}
}

View File

@@ -0,0 +1,7 @@
{
"description": "项目私有配置文件。此文件中的内容将覆盖 project.config.json 中的相同字段。项目的改动优先同步到此文件中。详见文档https://developers.weixin.qq.com/miniprogram/dev/devtools/projectconfig.html",
"projectname": "geminiWX",
"setting": {
"compileHotReLoad": true
}
}

View File

@@ -1,7 +1,7 @@
const env = 'development'
const configs = {
development: {
baseUrl: 'http://192.168.5.95:8000/api'
baseUrl: 'http://192.168.5.112:8000/api'
},
production: {
baseUrl: 'https://your-domain.example.com/api'

View File

@@ -43,13 +43,13 @@
<view class="category-item {{selectedCategory === 'all' ? 'active' : ''}}" bindtap="handleCategorySelect" data-type="all">
<text>全部</text>
</view>
<view class="category-item {{selectedCategory === 'training' ? 'active' : ''}}" bindtap="handleCategorySelect" data-type="training" wx:if="{{categoryStats.training > 0}}">
<view class="category-item {{selectedCategory === 'training' ? 'active' : ''}}" bindtap="handleCategorySelect" data-type="training">
<text>在职项目</text>
</view>
<view class="category-item {{selectedCategory === 'competition' ? 'active' : ''}}" bindtap="handleCategorySelect" data-type="competition" wx:if="{{categoryStats.competition > 0}}">
<view class="category-item {{selectedCategory === 'competition' ? 'active' : ''}}" bindtap="handleCategorySelect" data-type="competition">
<text>典礼&论坛</text>
</view>
<view class="category-item {{selectedCategory === 'grading' ? 'active' : ''}}" bindtap="handleCategorySelect" data-type="grading" wx:if="{{categoryStats.grading > 0}}">
<view class="category-item {{selectedCategory === 'grading' ? 'active' : ''}}" bindtap="handleCategorySelect" data-type="grading">
<text>校友活动</text>
</view>
</scroll-view>