浏览代码

组会通知

flower_bs 2 月之前
父节点
当前提交
522e4089de
共有 70 个文件被更改,包括 4254 次插入822 次删除
  1. 2 0
      greaterwms/settings.py
  2. 2 0
      greaterwms/urls.py
  3. 0 0
      groupMeeting/__init__.py
  4. 3 0
      groupMeeting/admin.py
  5. 6 0
      groupMeeting/apps.py
  6. 61 0
      groupMeeting/migrations/0001_initial.py
  7. 0 0
      groupMeeting/migrations/__init__.py
  8. 83 0
      groupMeeting/models.py
  9. 95 0
      groupMeeting/serializers.py
  10. 3 0
      groupMeeting/tests.py
  11. 11 0
      groupMeeting/urls.py
  12. 108 0
      groupMeeting/views.py
  13. 0 0
      invoice/__init__.py
  14. 3 0
      invoice/admin.py
  15. 6 0
      invoice/apps.py
  16. 29 0
      invoice/filter.py
  17. 58 0
      invoice/migrations/0001_initial.py
  18. 45 0
      invoice/migrations/0002_invoiceheader_create_time_invoiceheader_is_delete_and_more.py
  19. 18 0
      invoice/migrations/0003_alter_invoiceheader_tax_id.py
  20. 18 0
      invoice/migrations/0004_alter_invoiceheader_tax_id.py
  21. 0 0
      invoice/migrations/__init__.py
  22. 64 0
      invoice/models.py
  23. 42 0
      invoice/serializers.py
  24. 3 0
      invoice/tests.py
  25. 16 0
      invoice/urls.py
  26. 81 0
      invoice/views.py
  27. 2 2
      templates/mock/index.js
  28. 0 98
      templates/mock/role/index.js
  29. 0 530
      templates/mock/role/routes.js
  30. 2 1
      templates/package.json
  31. 310 0
      templates/src/components/DutyEditForm/index.vue
  32. 62 0
      templates/src/components/Guide/Steps.js
  33. 47 0
      templates/src/components/Guide/index.vue
  34. 61 0
      templates/src/components/OnlyOfficeEditor/index.vue
  35. 30 25
      templates/src/layout/components/Navbar.vue
  36. 1 1
      templates/src/layout/components/Settings/index.vue
  37. 1 0
      templates/src/layout/index.vue
  38. 66 25
      templates/src/router/index.js
  39. 2 1
      templates/src/router/modules/charts.js
  40. 1 0
      templates/src/router/modules/components.js
  41. 1 0
      templates/src/router/modules/nested.js
  42. 1 0
      templates/src/router/modules/table.js
  43. 9 9
      templates/src/store/modules/permission.js
  44. 26 9
      templates/src/views/dashboard/admin/index.vue
  45. 102 0
      templates/src/views/dashboard/editor/components/BarChart.vue
  46. 118 0
      templates/src/views/dashboard/editor/components/BoxCard.vue
  47. 135 0
      templates/src/views/dashboard/editor/components/LineChart.vue
  48. 181 0
      templates/src/views/dashboard/editor/components/PanelGroup.vue
  49. 79 0
      templates/src/views/dashboard/editor/components/PieChart.vue
  50. 116 0
      templates/src/views/dashboard/editor/components/RaddarChart.vue
  51. 81 0
      templates/src/views/dashboard/editor/components/TodoList/Todo.vue
  52. 320 0
      templates/src/views/dashboard/editor/components/TodoList/index.scss
  53. 127 0
      templates/src/views/dashboard/editor/components/TodoList/index.vue
  54. 55 0
      templates/src/views/dashboard/editor/components/TransactionTable.vue
  55. 55 0
      templates/src/views/dashboard/editor/components/mixins/resize.js
  56. 119 52
      templates/src/views/dashboard/editor/index.vue
  57. 1 1
      templates/src/views/dashboard/index.vue
  58. 4 7
      templates/src/views/error-page/404.vue
  59. 380 0
      templates/src/views/groupMeeting/index.vue
  60. 2 3
      templates/src/views/guide/index.vue
  61. 551 0
      templates/src/views/invoiceRecord/index.vue
  62. 308 0
      templates/src/views/invoiceRecord/invoiceHeader/index.vue
  63. 18 4
      templates/src/views/profile/components/Account.vue
  64. 3 3
      templates/src/views/profile/index.vue
  65. 43 0
      userprofile/migrations/0002_alter_academicprofile_department_and_more.py
  66. 13 3
      userprofile/models.py
  67. 16 3
      userprofile/serializers.py
  68. 3 1
      userprofile/urls.py
  69. 43 43
      userprofile/views.py
  70. 2 1
      userregister/views.py

+ 2 - 0
greaterwms/settings.py

@@ -27,6 +27,8 @@ INSTALLED_APPS = [
     'django.contrib.sessions',
     'django.contrib.sessions',
     'django.contrib.messages',
     'django.contrib.messages',
     'django.contrib.staticfiles',
     'django.contrib.staticfiles',
+    'invoice.apps.InvoiceConfig',
+    'groupMeeting.apps.GroupmeetingConfig',
 
 
     'userprofile.apps.UserprofileConfig',
     'userprofile.apps.UserprofileConfig',
     'userregister.apps.UserregisterConfig',
     'userregister.apps.UserregisterConfig',

+ 2 - 0
greaterwms/urls.py

@@ -17,6 +17,8 @@ urlpatterns = [
     path('login/', include('userlogin.urls')),
     path('login/', include('userlogin.urls')),
     path('register/', include('userregister.urls')),
     path('register/', include('userregister.urls')),
     path('user/', include('userprofile.urls')),
     path('user/', include('userprofile.urls')),
+    path('groupMeeting/',include ('groupMeeting.urls')),
+    path('invoice/', include('invoice.urls')),
 
 
     re_path(r'^favicon\.ico$', views.favicon, name='favicon'),
     re_path(r'^favicon\.ico$', views.favicon, name='favicon'),
     re_path('^css/.*$', views.css, name='css'),
     re_path('^css/.*$', views.css, name='css'),

+ 0 - 0
groupMeeting/__init__.py


+ 3 - 0
groupMeeting/admin.py

@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.

+ 6 - 0
groupMeeting/apps.py

@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class GroupmeetingConfig(AppConfig):
+    default_auto_field = 'django.db.models.BigAutoField'
+    name = 'groupMeeting'

+ 61 - 0
groupMeeting/migrations/0001_initial.py

@@ -0,0 +1,61 @@
+# Generated by Django 4.1.2 on 2025-08-08 17:01
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ('userprofile', '0002_alter_academicprofile_department_and_more'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='DutyRoster',
+            fields=[
+                ('duty_id', models.AutoField(primary_key=True, serialize=False, verbose_name='轮值ID')),
+                ('start_date', models.DateField(verbose_name='开始日期')),
+                ('end_date', models.DateField(verbose_name='结束日期')),
+                ('status', models.CharField(choices=[('ACT', '进行中'), ('UP', '即将开始'), ('COM', '已完成')], default='UP', max_length=3, verbose_name='状态')),
+                ('todo_items', models.TextField(blank=True, null=True, verbose_name='本周待办事项')),
+                ('notes', models.TextField(blank=True, null=True, verbose_name='教研室注意事项')),
+                ('cleaning_schedule', models.TextField(blank=True, null=True, verbose_name='打扫卫生安排')),
+                ('create_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
+                ('update_time', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
+                ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='duty_rosters', to='userprofile.users', verbose_name='轮值同学')),
+            ],
+            options={
+                'verbose_name': '轮值信息',
+                'verbose_name_plural': '轮值信息',
+                'db_table': 'duty_roster',
+                'ordering': ['-start_date'],
+            },
+        ),
+        migrations.CreateModel(
+            name='Meeting',
+            fields=[
+                ('meeting_id', models.AutoField(primary_key=True, serialize=False, verbose_name='组会ID')),
+                ('title', models.CharField(max_length=200, verbose_name='组会名称')),
+                ('meeting_type', models.CharField(choices=[('ON', '线上'), ('OFF', '线下'), ('HYB', '混合')], default='ON', max_length=3, verbose_name='会议形式')),
+                ('type_desc', models.CharField(blank=True, max_length=200, null=True, verbose_name='会议类型描述')),
+                ('location', models.CharField(blank=True, max_length=200, null=True, verbose_name='会议地点')),
+                ('online_link', models.URLField(blank=True, null=True, verbose_name='线上地址')),
+                ('meeting_time', models.DateTimeField(verbose_name='会议时间')),
+                ('summary', models.TextField(blank=True, null=True, verbose_name='会议纪要')),
+                ('summary_doc_id', models.CharField(blank=True, max_length=100, null=True, verbose_name='OnlyOffice文档ID')),
+                ('published', models.BooleanField(default=False, verbose_name='已发布')),
+                ('create_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
+                ('update_time', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
+                ('duty_roster', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meetings', to='groupMeeting.dutyroster', verbose_name='关联轮值')),
+            ],
+            options={
+                'verbose_name': '组会',
+                'verbose_name_plural': '组会',
+                'db_table': 'meeting',
+                'ordering': ['-meeting_time'],
+            },
+        ),
+    ]

+ 0 - 0
groupMeeting/migrations/__init__.py


+ 83 - 0
groupMeeting/models.py

@@ -0,0 +1,83 @@
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+from userprofile.models import Users
+
+class DutyRoster(models.Model):
+    """轮值信息模型"""
+    class Status(models.TextChoices):
+        ACTIVE = 'ACT', _('进行中')
+        UPCOMING = 'UP', _('即将开始')
+        COMPLETED = 'COM', _('已完成')
+
+    duty_id = models.AutoField(primary_key=True, verbose_name="轮值ID")
+    user = models.ForeignKey(
+        Users,
+        on_delete=models.CASCADE,
+        related_name='duty_rosters',
+        verbose_name='轮值同学'
+    )
+    start_date = models.DateField(verbose_name='开始日期')
+    end_date = models.DateField(verbose_name='结束日期')
+    status = models.CharField(
+        max_length=3,
+        choices=Status.choices,
+        default=Status.UPCOMING,
+        verbose_name='状态'
+    )
+    todo_items = models.TextField(verbose_name='本周待办事项', blank=True, null=True)
+    notes = models.TextField(verbose_name='教研室注意事项', blank=True, null=True)
+    cleaning_schedule = models.TextField(verbose_name='打扫卫生安排', blank=True, null=True)
+    
+    create_time = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
+    update_time = models.DateTimeField(auto_now=True, verbose_name='更新时间')
+
+    class Meta:
+        db_table = 'duty_roster'
+        verbose_name = '轮值信息'
+        verbose_name_plural = "轮值信息"
+        ordering = ['-start_date']
+
+    def __str__(self):
+        return f"{self.user.name} {self.start_date} ~ {self.end_date}"
+
+class Meeting(models.Model):
+    """组会模型"""
+    class MeetingType(models.TextChoices):
+        ONLINE = 'ON', _('线上')
+        OFFLINE = 'OFF', _('线下')
+        HYBRID = 'HYB', _('混合')
+    
+
+    meeting_id = models.AutoField(primary_key=True, verbose_name="组会ID")
+    title = models.CharField(max_length=200, verbose_name='组会名称')
+    meeting_type = models.CharField(
+        max_length=3,
+        choices=MeetingType.choices,
+        default=MeetingType.ONLINE,
+        verbose_name='会议形式'
+    )
+    type_desc = models.CharField(max_length=200, verbose_name='会议类型描述', blank=True, null=True)
+    location = models.CharField(max_length=200, verbose_name='会议地点', blank=True, null=True)
+    online_link = models.URLField(verbose_name='线上地址', blank=True, null=True)
+    meeting_time = models.DateTimeField(verbose_name='会议时间')
+    summary = models.TextField(verbose_name='会议纪要', blank=True, null=True)
+    summary_doc_id = models.CharField(max_length=100, verbose_name='OnlyOffice文档ID', blank=True, null=True)
+    duty_roster = models.ForeignKey(
+        DutyRoster,
+        on_delete=models.CASCADE,
+        related_name='meetings',
+        verbose_name='关联轮值'
+    )
+    published = models.BooleanField(default=False, verbose_name='已发布')
+    
+    create_time = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
+    update_time = models.DateTimeField(auto_now=True, verbose_name='更新时间')
+
+    class Meta:
+        db_table = 'meeting'
+        verbose_name = '组会'
+        verbose_name_plural = "组会"
+        ordering = ['-meeting_time']
+
+    def __str__(self):
+        return f"{self.title} ({self.get_meeting_type_display()})"

+ 95 - 0
groupMeeting/serializers.py

@@ -0,0 +1,95 @@
+from rest_framework import serializers
+from .models import DutyRoster, Meeting
+from userprofile.models import Users
+from userprofile.serializers import UsersGetSerializer
+
+
+class DutyRosterSerializer(serializers.ModelSerializer):
+    """轮值信息序列化器"""
+    user = UsersGetSerializer(read_only=True)
+    user_id = serializers.PrimaryKeyRelatedField(
+        source='user',
+        queryset=Users.objects.all(),
+        write_only=True
+    )
+    user_token = serializers.CharField(source='user.openid', read_only=True)
+
+    class Meta:
+        model = DutyRoster
+        fields = [
+            'duty_id', 'user', 'user_id', 'user_token','start_date', 'end_date',
+            'status', 'todo_items', 'notes', 'cleaning_schedule',
+            'create_time', 'update_time'
+        ]
+        read_only_fields = ['status', 'create_time', 'update_time']
+
+    def create(self, validated_data):
+        # 自动设置状态为即将开始
+        validated_data['status'] = DutyRoster.Status.UPCOMING
+        return super().create(validated_data)
+
+    def update(self, instance, validated_data):
+        # 自动更新状态
+        if 'start_date' in validated_data or 'end_date' in validated_data:
+            # 这里应添加状态更新逻辑
+            pass
+        return super().update(instance, validated_data)
+
+class MeetingSerializer(serializers.ModelSerializer):
+    """组会基础序列化器"""
+    class Meta:
+        model = Meeting
+        fields = [
+            'meeting_id', 'title', 'meeting_type', 'location', 
+            'online_link', 'meeting_time', 'published', 'duty_roster',
+            'create_time', 'update_time'
+        ]
+        read_only_fields = ['published', 'create_time', 'update_time']
+
+class MeetingDetailSerializer(serializers.ModelSerializer):
+    """组会详细序列化器(含会议纪要)"""
+    summary = serializers.CharField(
+        required=False, 
+        allow_null=True, 
+        allow_blank=True
+    )
+    
+    class Meta:
+        model = Meeting
+        fields = [
+            'meeting_id', 'title', 'meeting_type', 'location', 
+            'online_link', 'meeting_time', 'summary', 'duty_roster',
+            'published', 'create_time', 'update_time'
+        ]
+        read_only_fields = ['summary_doc_id', 'published', 'create_time', 'update_time']
+        
+    def create(self, validated_data):
+        # 创建OnlyOffice文档ID
+        validated_data['summary_doc_id'] = self.generate_doc_id()
+        return super().create(validated_data)
+    
+    def generate_doc_id(self):
+        """生成唯一的文档ID"""
+        import uuid
+        return f"meeting_{uuid.uuid4().hex[:12]}"
+
+# 权限控制
+from rest_framework.permissions import BasePermission
+
+class IsDutyPersonOrAdmin(BasePermission):
+    """检查用户是否为当前轮值同学或管理员"""
+    
+    def has_object_permission(self, request, view, obj):
+        # 管理员有所有权限
+        if request.user.roles in [Users.UserRole.ADMIN, Users.UserRole.DEVELOPER]:
+            return True
+        
+        # 对于会议操作,检查是否是会议关联轮值的同学
+        if isinstance(obj, Meeting):
+            return obj.duty_roster.user == request.user
+        
+        # 对于轮值操作,检查是否是轮值本人
+        if isinstance(obj, DutyRoster):
+            return obj.user == request.user
+        
+        return False

+ 3 - 0
groupMeeting/tests.py

@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.

+ 11 - 0
groupMeeting/urls.py

@@ -0,0 +1,11 @@
+from django.urls import path, re_path
+from . import views
+
+urlpatterns=[
+
+path('current/', views.DutyRosterViewSet.as_view({'get': 'current_duty'}), name='current_duty'),
+path('publish/', views.MeetingViewSet.as_view({'post': 'publish_meeting'}), name='publish_meeting'),
+path('meeting/', views.MeetingViewSet.as_view({'get': 'list'}), name='meeting'),
+re_path(r'^meeting/(?P<pk>[0-9]+)/$', views.MeetingViewSet.as_view({'get':'retrieve', 'post': 'update', 'delete': 'destroy'}), name='meeting_detail'),
+path('document/', views.MeetingViewSet.as_view({'get': 'get_document_url'}), name='get_document_url'),
+]

+ 108 - 0
groupMeeting/views.py

@@ -0,0 +1,108 @@
+from rest_framework import viewsets, permissions, status
+from rest_framework.decorators import action
+from rest_framework.response import Response
+from django_filters.rest_framework import DjangoFilterBackend
+from .models import DutyRoster, Meeting
+from .serializers import (
+    DutyRosterSerializer, 
+    MeetingSerializer, 
+    MeetingDetailSerializer
+)
+from utils.page import MyPageNumberPagination
+
+from .serializers import IsDutyPersonOrAdmin
+from datetime import date, timedelta  # 文件顶部导入
+
+
+    
+
+class DutyRosterViewSet(viewsets.ModelViewSet):
+    """轮值信息视图集"""
+    queryset = DutyRoster.objects.all().order_by('-start_date')
+    serializer_class = DutyRosterSerializer
+    filter_backends = [DjangoFilterBackend]
+    filterset_fields = ['status', 'user']
+    pagination_class = MyPageNumberPagination
+
+    def get_serializer_context(self):
+        """将当前用户添加到序列化器上下文中"""
+        context = super().get_serializer_context()
+        context.update({'request': self.request})
+        return context
+
+    def current_duty(self, request):
+        """获取当前轮值信息"""
+        current_duty = DutyRoster.objects.filter(
+            status=DutyRoster.Status.ACTIVE
+        ).first()
+        
+        if not current_duty:
+     
+            from userprofile.models import Users
+            # 从周一开始创建轮值信息
+            start_date = date.today() - timedelta(days=date.today().weekday())
+            end_date = start_date + timedelta(days=13)
+            base_user = Users.objects.filter(roles=Users.UserRole.ADMIN).first()
+            if not base_user:
+                return Response({"detail": "系统尚未配置管理员账号"}, status=200)
+            current_duty = DutyRoster.objects.create(
+                user=base_user,
+                start_date=start_date,
+                end_date=end_date,
+                status=DutyRoster.Status.ACTIVE
+            )
+        
+            serializer = self.get_serializer(current_duty)
+
+            return Response(serializer.data)
+
+
+
+            
+        serializer = self.get_serializer(current_duty)
+        return Response(serializer.data)
+
+class MeetingViewSet(viewsets.ModelViewSet):
+    """组会视图集"""
+    queryset = Meeting.objects.all().order_by('-meeting_time')
+    serializer_class = MeetingSerializer
+    filter_backends = [DjangoFilterBackend]
+    filterset_fields = ['duty_roster', 'published', 'meeting_type']
+    pagination_class = MyPageNumberPagination
+
+    def get_serializer_class(self):
+        if self.action in ['retrieve', 'create', 'update']:
+            return MeetingDetailSerializer
+        return MeetingSerializer
+
+
+    def publish_meeting(self, request, pk=None):
+        """发布会议通知"""
+        meeting = self.get_object()
+        
+        # 检查当前用户是否为轮值同学
+        if not meeting.duty_roster.user == request.user:
+            return Response({"detail": "只有当前轮值同学可以发布会议通知"}, status=200)
+        
+        meeting.published = True
+        meeting.save()
+        
+        # 在此处添加实际发布通知的逻辑
+        return Response({"status": "会议已发布"}, status=200)
+
+
+    def get_document_url(self, request, pk=None):
+        """获取OnlyOffice文档URL"""
+        meeting = self.get_object()
+        if not meeting.summary_doc_id:
+            return Response({"detail": "文档尚未创建"}, status=200)
+        
+        # 实际系统中应根据配置生成OnlyOffice文档URL
+        document_url = f"/onlyoffice/{meeting.summary_doc_id}"
+        return Response({"document_url": document_url})
+
+    def update(self, request, *args, **kwargs):
+        return super().update(request, *args, **kwargs)
+
+    def destroy(self, request, *args, **kwargs):
+        return super().destroy(request, *args, **kwargs)

+ 0 - 0
invoice/__init__.py


+ 3 - 0
invoice/admin.py

@@ -0,0 +1,3 @@
+from django.contrib import admin
+
+# Register your models here.

+ 6 - 0
invoice/apps.py

@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class InvoiceConfig(AppConfig):
+    default_auto_field = 'django.db.models.BigAutoField'
+    name = 'invoice'

+ 29 - 0
invoice/filter.py

@@ -0,0 +1,29 @@
+from django_filters import FilterSet
+from .models import InvoiceHeader, InvoiceRecord
+
+class InvoiceHeaderFilter(FilterSet):
+    class Meta:
+        model = InvoiceHeader
+        fields = {
+            'name': ['icontains', 'exact'],
+            'tax_id': ['icontains', 'exact'],
+            'address': ['icontains', 'exact'],
+            'bank': ['icontains', 'exact'],
+            'account': ['icontains', 'exact'],
+            'create_time': ['exact', 'lt', 'gt'],
+            'update_time': ['exact', 'lt', 'gt'],
+        }
+
+class InvoiceRecordFilter(FilterSet):
+    class Meta:
+        model = InvoiceRecord
+        fields = {
+            'amount': ['exact', 'lt', 'gt'],
+            'date': ['exact', 'lt', 'gt'],
+            'purpose': ['icontains', 'exact'],
+            'project': ['icontains', 'exact'],
+            'actual_paid': ['exact', 'lt', 'gt'],
+            'is_reported': ['exact'],
+            'create_time': ['exact', 'lt', 'gt'],
+            'update_time': ['exact', 'lt', 'gt'],
+        }

+ 58 - 0
invoice/migrations/0001_initial.py

@@ -0,0 +1,58 @@
+# Generated by Django 4.1.2 on 2025-08-08 14:41
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ('userprofile', '0002_alter_academicprofile_department_and_more'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='InvoiceHeader',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=100, verbose_name='单位名称')),
+                ('tax_id', models.CharField(max_length=50, verbose_name='纳税人识别号')),
+                ('address', models.CharField(max_length=200, verbose_name='地址')),
+                ('bank', models.CharField(max_length=100, verbose_name='开户银行')),
+                ('account', models.CharField(max_length=50, verbose_name='银行账号')),
+            ],
+            options={
+                'verbose_name': '发票抬头信息',
+                'verbose_name_plural': '发票抬头信息',
+                'db_table': 'invoice_header',
+                'ordering': ['name'],
+            },
+        ),
+        migrations.CreateModel(
+            name='InvoiceRecord',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('amount', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='金额')),
+                ('date', models.DateField(verbose_name='时间')),
+                ('purpose', models.CharField(max_length=100, verbose_name='用途')),
+                ('project', models.CharField(max_length=100, verbose_name='归属项目')),
+                ('actual_paid', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='实付金额')),
+                ('is_reported', models.BooleanField(default=False, verbose_name='是否申报')),
+                ('invoice_attachment', models.FileField(blank=True, null=True, upload_to='invoices/', verbose_name='发票附件')),
+                ('payment_attachment', models.FileField(blank=True, null=True, upload_to='payments/', verbose_name='支付附件')),
+                ('status', models.CharField(choices=[('P', '待审批'), ('A', '已通过'), ('R', '已拒绝')], default='P', max_length=1, verbose_name='审批状态')),
+                ('create_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
+                ('update_time', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
+                ('header', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invoices', to='invoice.invoiceheader', verbose_name='单位抬头')),
+                ('teacher', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='teacher_invoices', to='userprofile.users', verbose_name='归属老师')),
+            ],
+            options={
+                'verbose_name': '发票记录',
+                'verbose_name_plural': '发票记录',
+                'db_table': 'invoice_record',
+                'ordering': ['-date'],
+            },
+        ),
+    ]

+ 45 - 0
invoice/migrations/0002_invoiceheader_create_time_invoiceheader_is_delete_and_more.py

@@ -0,0 +1,45 @@
+# Generated by Django 4.1.2 on 2025-08-08 15:01
+
+from django.db import migrations, models
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('invoice', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='invoiceheader',
+            name='create_time',
+            field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, verbose_name='创建时间'),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name='invoiceheader',
+            name='is_delete',
+            field=models.BooleanField(default=False, verbose_name='删除标记'),
+        ),
+        migrations.AddField(
+            model_name='invoiceheader',
+            name='update_time',
+            field=models.DateTimeField(auto_now=True, verbose_name='更新时间'),
+        ),
+        migrations.AlterField(
+            model_name='invoiceheader',
+            name='account',
+            field=models.CharField(blank=True, max_length=50, null=True, verbose_name='银行账号'),
+        ),
+        migrations.AlterField(
+            model_name='invoiceheader',
+            name='address',
+            field=models.CharField(blank=True, max_length=200, null=True, verbose_name='地址'),
+        ),
+        migrations.AlterField(
+            model_name='invoiceheader',
+            name='bank',
+            field=models.CharField(blank=True, max_length=100, null=True, verbose_name='开户银行'),
+        ),
+    ]

+ 18 - 0
invoice/migrations/0003_alter_invoiceheader_tax_id.py

@@ -0,0 +1,18 @@
+# Generated by Django 4.1.2 on 2025-08-08 15:11
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('invoice', '0002_invoiceheader_create_time_invoiceheader_is_delete_and_more'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='invoiceheader',
+            name='tax_id',
+            field=models.BigIntegerField(max_length=50, unique=True, verbose_name='纳税人识别号'),
+        ),
+    ]

+ 18 - 0
invoice/migrations/0004_alter_invoiceheader_tax_id.py

@@ -0,0 +1,18 @@
+# Generated by Django 4.1.2 on 2025-08-08 15:22
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('invoice', '0003_alter_invoiceheader_tax_id'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='invoiceheader',
+            name='tax_id',
+            field=models.BigIntegerField(unique=True, verbose_name='纳税人识别号'),
+        ),
+    ]

+ 0 - 0
invoice/migrations/__init__.py


+ 64 - 0
invoice/models.py

@@ -0,0 +1,64 @@
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+from userprofile.models import Users
+
+class InvoiceHeader(models.Model):
+    """单位抬头信息表"""
+    name = models.CharField(max_length=100, verbose_name="单位名称")
+    tax_id = models.BigIntegerField(unique=True, verbose_name="纳税人识别号")
+    address = models.CharField(max_length=200,null=True, blank=True, verbose_name="地址")
+    bank = models.CharField(max_length=100,null=True, blank=True, verbose_name="开户银行")
+    account = models.CharField(max_length=50,null=True, blank=True, verbose_name="银行账号")
+    create_time = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
+    update_time = models.DateTimeField(auto_now=True, verbose_name="更新时间")
+    is_delete = models.BooleanField(default=False, verbose_name="删除标记")
+
+    class Meta:
+        db_table = 'invoice_header'
+        verbose_name = '发票抬头信息'
+        verbose_name_plural = '发票抬头信息'
+        ordering = ['name']
+
+    def __str__(self):
+        return f"{self.name} ({self.tax_id})"
+
+class InvoiceRecord(models.Model):
+    """发票申报记录表"""
+    header = models.ForeignKey(
+        InvoiceHeader, on_delete=models.CASCADE,
+        related_name='invoices', verbose_name="单位抬头"
+    )
+    amount = models.DecimalField(max_digits=12, decimal_places=2, verbose_name="金额")
+    date = models.DateField(verbose_name="时间")
+    purpose = models.CharField(max_length=100, verbose_name="用途")
+    teacher = models.ForeignKey(
+        Users, related_name='teacher_invoices', on_delete=models.SET_NULL, null=True, verbose_name="归属老师"
+    )
+    project = models.CharField(max_length=100, verbose_name="归属项目")
+    actual_paid = models.DecimalField(max_digits=12, decimal_places=2, verbose_name="实付金额")
+    is_reported = models.BooleanField(default=False, verbose_name="是否申报")
+    invoice_attachment = models.FileField(
+        upload_to='invoices/', blank=True, null=True, verbose_name="发票附件"
+    )
+    payment_attachment = models.FileField(
+        upload_to='payments/', blank=True, null=True, verbose_name="支付附件"
+    )
+    class ApprovalStatus(models.TextChoices):
+        PENDING = 'P', _('待审批')
+        APPROVED = 'A', _('已通过')
+        REJECTED = 'R', _('已拒绝')
+    status = models.CharField(
+        max_length=1, choices=ApprovalStatus.choices,
+        default=ApprovalStatus.PENDING, verbose_name="审批状态"
+    )
+    create_time = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
+    update_time = models.DateTimeField(auto_now=True, verbose_name="更新时间")
+
+    class Meta:
+        db_table = 'invoice_record'
+        verbose_name = '发票记录'
+        verbose_name_plural = '发票记录'
+        ordering = ['-date']
+
+    def __str__(self):
+        return f"Invoice #{self.id} - {self.header.name}"

+ 42 - 0
invoice/serializers.py

@@ -0,0 +1,42 @@
+from rest_framework import serializers
+from .models import InvoiceHeader, InvoiceRecord
+
+class InvoiceHeaderGetSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = InvoiceHeader
+        fields = '__all__'
+
+class InvoiceHeaderPostSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = InvoiceHeader
+        fields = '__all__'
+
+class InvoiceHeaderPatchSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = InvoiceHeader
+        fields = '__all__'
+
+class InvoiceHeaderDeleteSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = InvoiceHeader
+        fields = '__all__'
+        
+class InvoiceRecordGetSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = InvoiceRecord
+        fields = '__all__'
+
+class InvoiceRecordPostSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = InvoiceRecord
+        fields = '__all__'
+
+class InvoiceRecordPatchSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = InvoiceRecord
+        fields = '__all__'
+
+class InvoiceRecordDeleteSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = InvoiceRecord
+        fields = '__all__'

+ 3 - 0
invoice/tests.py

@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.

+ 16 - 0
invoice/urls.py

@@ -0,0 +1,16 @@
+from django.urls import path, re_path
+from . import views
+
+urlpatterns=[
+
+path('header/', views.InvoiceHeaderViewSet.as_view({'get': 'list','post': 'create'}), name='invoice-header'),
+re_path(r'^header/(?P<pk>[0-9]+)/$', views.InvoiceHeaderViewSet.as_view({'get': 'retrieve', 'post': 'update', 'patch': 'partial_update', 'delete': 'destroy'}), name='invoice-header-detail')
+
+# path('record/', views.InvoiceRecordViewSet.as_view({'get': 'list','post': 'update'}), name='invoice-record'),
+# re_path(r'^header/(?P<pk>[0-9]+)/$', views.InvoiceRecordViewSet.as_view({'get': 'retrieve', 'post': 'update', 'patch': 'partial_update', 'delete': 'destroy'}), name='invoice-record-detail'),
+
+
+# path('avatar-upload/', views.AvatarUploadView.as_view(), name='groups'),
+
+
+]

+ 81 - 0
invoice/views.py

@@ -0,0 +1,81 @@
+from rest_framework import viewsets, status
+from rest_framework.parsers import MultiPartParser, FormParser
+from rest_framework.permissions import IsAuthenticated, IsAdminUser
+from rest_framework.response import Response
+from django_filters.rest_framework import DjangoFilterBackend
+from rest_framework.filters import OrderingFilter, SearchFilter
+from utils.page import MyPageNumberPagination
+
+from .models import InvoiceHeader, InvoiceRecord
+from .serializers import InvoiceHeaderGetSerializer, InvoiceHeaderPostSerializer
+from .serializers import InvoiceHeaderPatchSerializer, InvoiceHeaderDeleteSerializer, InvoiceRecordGetSerializer, InvoiceRecordPostSerializer, InvoiceRecordPatchSerializer, InvoiceRecordDeleteSerializer
+from .filter import InvoiceHeaderFilter, InvoiceRecordFilter
+
+class InvoiceHeaderViewSet(viewsets.ModelViewSet):
+    """
+    发票头视图集
+        retrieve:
+            Response a data list (get)
+
+        list:
+            Response a data list (all)
+
+        create:
+            Create a data line (post)
+
+        delete:
+            Delete a data line (delete)
+
+        partial_update:
+            Partial_update a data (patch:partial_update)
+
+        update:
+            Update a data (put:update)
+    """
+    fliter_backends = (DjangoFilterBackend, OrderingFilter)
+    ordering_fields = ('create_time', 'update_time')
+    filterset_class = InvoiceHeaderFilter
+    pagination_class = MyPageNumberPagination
+    def get_project(self):
+        try:
+            id = self.kwargs.get('pk')
+            return id
+        except:
+            return None
+
+    def get_queryset(self):
+        project_id = self.get_project()
+        if project_id:
+            return InvoiceHeader.objects.filter(id=project_id,is_delete=False)
+            
+        else:
+            return InvoiceHeader.objects.filter(is_delete=False)
+
+    def get_serializer_class(self):
+        if self.action in ['list','retrieve']:
+            return InvoiceHeaderGetSerializer
+        elif self.action in ['create', 'update', 'partial_update']:
+            return InvoiceHeaderPostSerializer
+        else:
+            return InvoiceHeaderGetSerializer
+
+    def create(self, request, *args, **kwargs):
+        serializer = self.get_serializer(data=request.data)
+        serializer.is_valid(raise_exception=True)
+        serializer.save()
+        headers = self.get_success_headers(serializer.data)
+        return Response(serializer.data, status=status.HTTP_200_OK, headers=headers)
+            
+
+    def update(self, request, *args, **kwargs):
+        qs = self.get_object()
+        serializer = self.get_serializer(qs, data=request.data, partial=True)
+        serializer.is_valid(raise_exception=True)
+        serializer.save()
+        headers = self.get_success_headers(serializer.data)
+        return Response(serializer.data, status=status.HTTP_200_OK, headers=headers)
+
+    def partial_update(self, request, *args, **kwargs):
+        return super().partial_update(request, *args, **kwargs)
+
+

+ 2 - 2
templates/mock/index.js

@@ -2,13 +2,13 @@ const Mock = require('mockjs')
 const { param2Obj } = require('./utils')
 const { param2Obj } = require('./utils')
 
 
 const user = require('./user')
 const user = require('./user')
-const role = require('./role')
+// const role = require('./role')
 const article = require('./article')
 const article = require('./article')
 const search = require('./remote-search')
 const search = require('./remote-search')
 
 
 const mocks = [
 const mocks = [
   ...user,
   ...user,
-  ...role,
+  // ...role,
   ...article,
   ...article,
   ...search
   ...search
 ]
 ]

+ 0 - 98
templates/mock/role/index.js

@@ -1,98 +0,0 @@
-const Mock = require('mockjs')
-const { deepClone } = require('../utils')
-const { asyncRoutes, constantRoutes } = require('./routes.js')
-
-const routes = deepClone([...constantRoutes, ...asyncRoutes])
-
-const roles = [
-  {
-    key: 'admin',
-    name: 'admin',
-    description: 'Super Administrator. Have access to view all pages.',
-    routes: routes
-  },
-  {
-    key: 'editor',
-    name: 'editor',
-    description: 'Normal Editor. Can see all pages except permission page',
-    routes: routes.filter(i => i.path !== '/permission')// just a mock
-  },
-  {
-    key: 'visitor',
-    name: 'visitor',
-    description: 'Just a visitor. Can only see the home page and the document page',
-    routes: [{
-      path: '',
-      redirect: 'dashboard',
-      children: [
-        {
-          path: 'dashboard',
-          name: 'Dashboard',
-          meta: { title: 'dashboard', icon: 'dashboard' }
-        }
-      ]
-    }]
-  }
-]
-
-module.exports = [
-  // mock get all routes form server
-  {
-    url: '/vue-element-admin/routes',
-    type: 'get',
-    response: _ => {
-      return {
-        code: 20000,
-        data: routes
-      }
-    }
-  },
-
-  // mock get all roles form server
-  {
-    url: '/vue-element-admin/roles',
-    type: 'get',
-    response: _ => {
-      return {
-        code: 20000,
-        data: roles
-      }
-    }
-  },
-
-  // add role
-  {
-    url: '/vue-element-admin/role',
-    type: 'post',
-    response: {
-      code: 20000,
-      data: {
-        key: Mock.mock('@integer(300, 5000)')
-      }
-    }
-  },
-
-  // update role
-  {
-    url: '/vue-element-admin/role/[A-Za-z0-9]',
-    type: 'put',
-    response: {
-      code: 20000,
-      data: {
-        status: 'success'
-      }
-    }
-  },
-
-  // delete role
-  {
-    url: '/vue-element-admin/role/[A-Za-z0-9]',
-    type: 'delete',
-    response: {
-      code: 20000,
-      data: {
-        status: 'success'
-      }
-    }
-  }
-]

+ 0 - 530
templates/mock/role/routes.js

@@ -1,530 +0,0 @@
-// Just a mock data
-
-const constantRoutes = [
-  {
-    path: '/redirect',
-    component: 'layout/Layout',
-    hidden: true,
-    children: [
-      {
-        path: '/redirect/:path*',
-        component: 'views/redirect/index'
-      }
-    ]
-  },
-  {
-    path: '/login',
-    component: 'views/login/index',
-    hidden: true
-  },
-  {
-    path: '/auth-redirect',
-    component: 'views/login/auth-redirect',
-    hidden: true
-  },
-  {
-    path: '/404',
-    component: 'views/error-page/404',
-    hidden: true
-  },
-  {
-    path: '/401',
-    component: 'views/error-page/401',
-    hidden: true
-  },
-  {
-    path: '',
-    component: 'layout/Layout',
-    redirect: 'dashboard',
-    children: [
-      {
-        path: 'dashboard',
-        component: 'views/dashboard/index',
-        name: 'Dashboard',
-        meta: { title: 'Dashboard', icon: 'dashboard', affix: true }
-      }
-    ]
-  },
-  {
-    path: '/documentation',
-    component: 'layout/Layout',
-    children: [
-      {
-        path: 'index',
-        component: 'views/documentation/index',
-        name: 'Documentation',
-        meta: { title: 'Documentation', icon: 'documentation', affix: true }
-      }
-    ]
-  },
-  {
-    path: '/guide',
-    component: 'layout/Layout',
-    redirect: '/guide/index',
-    children: [
-      {
-        path: 'index',
-        component: 'views/guide/index',
-        name: 'Guide',
-        meta: { title: 'Guide', icon: 'guide', noCache: true }
-      }
-    ]
-  }
-]
-
-const asyncRoutes = [
-  {
-    path: '/permission',
-    component: 'layout/Layout',
-    redirect: '/permission/index',
-    alwaysShow: true,
-    meta: {
-      title: 'Permission',
-      icon: 'lock',
-      roles: ['admin', 'editor']
-    },
-    children: [
-      {
-        path: 'page',
-        component: 'views/permission/page',
-        name: 'PagePermission',
-        meta: {
-          title: 'Page Permission',
-          roles: ['admin']
-        }
-      },
-      {
-        path: 'directive',
-        component: 'views/permission/directive',
-        name: 'DirectivePermission',
-        meta: {
-          title: 'Directive Permission'
-        }
-      },
-      {
-        path: 'role',
-        component: 'views/permission/role',
-        name: 'RolePermission',
-        meta: {
-          title: 'Role Permission',
-          roles: ['admin']
-        }
-      }
-    ]
-  },
-
-  {
-    path: '/icon',
-    component: 'layout/Layout',
-    children: [
-      {
-        path: 'index',
-        component: 'views/icons/index',
-        name: 'Icons',
-        meta: { title: 'Icons', icon: 'icon', noCache: true }
-      }
-    ]
-  },
-
-  {
-    path: '/components',
-    component: 'layout/Layout',
-    redirect: 'noRedirect',
-    name: 'ComponentDemo',
-    meta: {
-      title: 'Components',
-      icon: 'component'
-    },
-    children: [
-      {
-        path: 'tinymce',
-        component: 'views/components-demo/tinymce',
-        name: 'TinymceDemo',
-        meta: { title: 'Tinymce' }
-      },
-      {
-        path: 'markdown',
-        component: 'views/components-demo/markdown',
-        name: 'MarkdownDemo',
-        meta: { title: 'Markdown' }
-      },
-      {
-        path: 'json-editor',
-        component: 'views/components-demo/json-editor',
-        name: 'JsonEditorDemo',
-        meta: { title: 'Json Editor' }
-      },
-      {
-        path: 'split-pane',
-        component: 'views/components-demo/split-pane',
-        name: 'SplitpaneDemo',
-        meta: { title: 'SplitPane' }
-      },
-      {
-        path: 'avatar-upload',
-        component: 'views/components-demo/avatar-upload',
-        name: 'AvatarUploadDemo',
-        meta: { title: 'Avatar Upload' }
-      },
-      {
-        path: 'dropzone',
-        component: 'views/components-demo/dropzone',
-        name: 'DropzoneDemo',
-        meta: { title: 'Dropzone' }
-      },
-      {
-        path: 'sticky',
-        component: 'views/components-demo/sticky',
-        name: 'StickyDemo',
-        meta: { title: 'Sticky' }
-      },
-      {
-        path: 'count-to',
-        component: 'views/components-demo/count-to',
-        name: 'CountToDemo',
-        meta: { title: 'Count To' }
-      },
-      {
-        path: 'mixin',
-        component: 'views/components-demo/mixin',
-        name: 'ComponentMixinDemo',
-        meta: { title: 'componentMixin' }
-      },
-      {
-        path: 'back-to-top',
-        component: 'views/components-demo/back-to-top',
-        name: 'BackToTopDemo',
-        meta: { title: 'Back To Top' }
-      },
-      {
-        path: 'drag-dialog',
-        component: 'views/components-demo/drag-dialog',
-        name: 'DragDialogDemo',
-        meta: { title: 'Drag Dialog' }
-      },
-      {
-        path: 'drag-select',
-        component: 'views/components-demo/drag-select',
-        name: 'DragSelectDemo',
-        meta: { title: 'Drag Select' }
-      },
-      {
-        path: 'dnd-list',
-        component: 'views/components-demo/dnd-list',
-        name: 'DndListDemo',
-        meta: { title: 'Dnd List' }
-      },
-      {
-        path: 'drag-kanban',
-        component: 'views/components-demo/drag-kanban',
-        name: 'DragKanbanDemo',
-        meta: { title: 'Drag Kanban' }
-      }
-    ]
-  },
-  {
-    path: '/charts',
-    component: 'layout/Layout',
-    redirect: 'noRedirect',
-    name: 'Charts',
-    meta: {
-      title: 'Charts',
-      icon: 'chart'
-    },
-    children: [
-      {
-        path: 'keyboard',
-        component: 'views/charts/keyboard',
-        name: 'KeyboardChart',
-        meta: { title: 'Keyboard Chart', noCache: true }
-      },
-      {
-        path: 'line',
-        component: 'views/charts/line',
-        name: 'LineChart',
-        meta: { title: 'Line Chart', noCache: true }
-      },
-      {
-        path: 'mixchart',
-        component: 'views/charts/mixChart',
-        name: 'MixChart',
-        meta: { title: 'Mix Chart', noCache: true }
-      }
-    ]
-  },
-  {
-    path: '/nested',
-    component: 'layout/Layout',
-    redirect: '/nested/menu1/menu1-1',
-    name: 'Nested',
-    meta: {
-      title: 'Nested',
-      icon: 'nested'
-    },
-    children: [
-      {
-        path: 'menu1',
-        component: 'views/nested/menu1/index',
-        name: 'Menu1',
-        meta: { title: 'Menu1' },
-        redirect: '/nested/menu1/menu1-1',
-        children: [
-          {
-            path: 'menu1-1',
-            component: 'views/nested/menu1/menu1-1',
-            name: 'Menu1-1',
-            meta: { title: 'Menu1-1' }
-          },
-          {
-            path: 'menu1-2',
-            component: 'views/nested/menu1/menu1-2',
-            name: 'Menu1-2',
-            redirect: '/nested/menu1/menu1-2/menu1-2-1',
-            meta: { title: 'Menu1-2' },
-            children: [
-              {
-                path: 'menu1-2-1',
-                component: 'views/nested/menu1/menu1-2/menu1-2-1',
-                name: 'Menu1-2-1',
-                meta: { title: 'Menu1-2-1' }
-              },
-              {
-                path: 'menu1-2-2',
-                component: 'views/nested/menu1/menu1-2/menu1-2-2',
-                name: 'Menu1-2-2',
-                meta: { title: 'Menu1-2-2' }
-              }
-            ]
-          },
-          {
-            path: 'menu1-3',
-            component: 'views/nested/menu1/menu1-3',
-            name: 'Menu1-3',
-            meta: { title: 'Menu1-3' }
-          }
-        ]
-      },
-      {
-        path: 'menu2',
-        name: 'Menu2',
-        component: 'views/nested/menu2/index',
-        meta: { title: 'Menu2' }
-      }
-    ]
-  },
-
-  {
-    path: '/example',
-    component: 'layout/Layout',
-    redirect: '/example/list',
-    name: 'Example',
-    meta: {
-      title: 'Example',
-      icon: 'example'
-    },
-    children: [
-      {
-        path: 'create',
-        component: 'views/example/create',
-        name: 'CreateArticle',
-        meta: { title: 'Create Article', icon: 'edit' }
-      },
-      {
-        path: 'edit/:id(\\d+)',
-        component: 'views/example/edit',
-        name: 'EditArticle',
-        meta: { title: 'Edit Article', noCache: true },
-        hidden: true
-      },
-      {
-        path: 'list',
-        component: 'views/example/list',
-        name: 'ArticleList',
-        meta: { title: 'Article List', icon: 'list' }
-      }
-    ]
-  },
-
-  {
-    path: '/tab',
-    component: 'layout/Layout',
-    children: [
-      {
-        path: 'index',
-        component: 'views/tab/index',
-        name: 'Tab',
-        meta: { title: 'Tab', icon: 'tab' }
-      }
-    ]
-  },
-
-  {
-    path: '/error',
-    component: 'layout/Layout',
-    redirect: 'noRedirect',
-    name: 'ErrorPages',
-    meta: {
-      title: 'Error Pages',
-      icon: '404'
-    },
-    children: [
-      {
-        path: '401',
-        component: 'views/error-page/401',
-        name: 'Page401',
-        meta: { title: 'Page 401', noCache: true }
-      },
-      {
-        path: '404',
-        component: 'views/error-page/404',
-        name: 'Page404',
-        meta: { title: 'Page 404', noCache: true }
-      }
-    ]
-  },
-
-  {
-    path: '/error-log',
-    component: 'layout/Layout',
-    redirect: 'noRedirect',
-    children: [
-      {
-        path: 'log',
-        component: 'views/error-log/index',
-        name: 'ErrorLog',
-        meta: { title: 'Error Log', icon: 'bug' }
-      }
-    ]
-  },
-
-  {
-    path: '/excel',
-    component: 'layout/Layout',
-    redirect: '/excel/export-excel',
-    name: 'Excel',
-    meta: {
-      title: 'Excel',
-      icon: 'excel'
-    },
-    children: [
-      {
-        path: 'export-excel',
-        component: 'views/excel/export-excel',
-        name: 'ExportExcel',
-        meta: { title: 'Export Excel' }
-      },
-      {
-        path: 'export-selected-excel',
-        component: 'views/excel/select-excel',
-        name: 'SelectExcel',
-        meta: { title: 'Select Excel' }
-      },
-      {
-        path: 'export-merge-header',
-        component: 'views/excel/merge-header',
-        name: 'MergeHeader',
-        meta: { title: 'Merge Header' }
-      },
-      {
-        path: 'upload-excel',
-        component: 'views/excel/upload-excel',
-        name: 'UploadExcel',
-        meta: { title: 'Upload Excel' }
-      }
-    ]
-  },
-
-  {
-    path: '/zip',
-    component: 'layout/Layout',
-    redirect: '/zip/download',
-    alwaysShow: true,
-    meta: { title: 'Zip', icon: 'zip' },
-    children: [
-      {
-        path: 'download',
-        component: 'views/zip/index',
-        name: 'ExportZip',
-        meta: { title: 'Export Zip' }
-      }
-    ]
-  },
-
-  {
-    path: '/pdf',
-    component: 'layout/Layout',
-    redirect: '/pdf/index',
-    children: [
-      {
-        path: 'index',
-        component: 'views/pdf/index',
-        name: 'PDF',
-        meta: { title: 'PDF', icon: 'pdf' }
-      }
-    ]
-  },
-  {
-    path: '/pdf/download',
-    component: 'views/pdf/download',
-    hidden: true
-  },
-
-  {
-    path: '/theme',
-    component: 'layout/Layout',
-    redirect: 'noRedirect',
-    children: [
-      {
-        path: 'index',
-        component: 'views/theme/index',
-        name: 'Theme',
-        meta: { title: 'Theme', icon: 'theme' }
-      }
-    ]
-  },
-
-  {
-    path: '/clipboard',
-    component: 'layout/Layout',
-    redirect: 'noRedirect',
-    children: [
-      {
-        path: 'index',
-        component: 'views/clipboard/index',
-        name: 'ClipboardDemo',
-        meta: { title: 'Clipboard Demo', icon: 'clipboard' }
-      }
-    ]
-  },
-
-  {
-    path: '/i18n',
-    component: 'layout/Layout',
-    children: [
-      {
-        path: 'index',
-        component: 'views/i18n-demo/index',
-        name: 'I18n',
-        meta: { title: 'I18n', icon: 'international' }
-      }
-    ]
-  },
-
-  {
-    path: 'external-link',
-    component: 'layout/Layout',
-    children: [
-      {
-        path: 'https://github.com/PanJiaChen/vue-element-admin',
-        meta: { title: 'External Link', icon: 'link' }
-      }
-    ]
-  },
-
-  { path: '*', redirect: '/404', hidden: true }
-]
-
-module.exports = {
-  constantRoutes,
-  asyncRoutes
-}

+ 2 - 1
templates/package.json

@@ -28,8 +28,10 @@
     "js-cookie": "2.2.0",
     "js-cookie": "2.2.0",
     "jsonlint": "1.6.3",
     "jsonlint": "1.6.3",
     "jszip": "3.2.1",
     "jszip": "3.2.1",
+    "lottie-web": "^5.7.11",
     "normalize.css": "7.0.0",
     "normalize.css": "7.0.0",
     "nprogress": "0.2.0",
     "nprogress": "0.2.0",
+    "onlyoffice-vue": "^1.0.1",
     "path-to-regexp": "2.4.0",
     "path-to-regexp": "2.4.0",
     "screenfull": "4.2.0",
     "screenfull": "4.2.0",
     "script-loader": "0.7.2",
     "script-loader": "0.7.2",
@@ -41,7 +43,6 @@
     "vue-splitpane": "1.0.4",
     "vue-splitpane": "1.0.4",
     "vuedraggable": "2.20.0",
     "vuedraggable": "2.20.0",
     "vuex": "3.1.0",
     "vuex": "3.1.0",
-    "lottie-web": "^5.7.11",
     "xlsx": "0.14.1"
     "xlsx": "0.14.1"
   },
   },
   "devDependencies": {
   "devDependencies": {

+ 310 - 0
templates/src/components/DutyEditForm/index.vue

@@ -0,0 +1,310 @@
+<template>
+  <el-form
+    ref="dutyForm"
+    :model="form"
+    :rules="rules"
+    label-width="130px"
+    class="duty-edit-form"
+  >
+    <el-form-item label="轮值同学" prop="user_id">
+      <el-select
+        v-model="form.user_id"
+        placeholder="选择轮值同学"
+        filterable
+        style="width: 100%"
+        :disabled="dutyId !== null"
+      >
+        <el-option
+          v-for="user in users"
+          :key="user.user_id"
+          :label="user.name"
+          :value="user.user_id"
+        />
+      </el-select>
+    </el-form-item>
+
+    <el-form-item label="轮值时间" required>
+      <el-col :span="11">
+        <el-form-item prop="start_date">
+          <el-date-picker
+            v-model="form.start_date"
+            type="date"
+            placeholder="开始日期"
+            style="width: 100%"
+            :picker-options="startPickerOptions"
+          />
+        </el-form-item>
+      </el-col>
+      <el-col :span="2" style="text-align: center">-</el-col>
+      <el-col :span="11">
+        <el-form-item prop="end_date">
+          <el-date-picker
+            v-model="form.end_date"
+            type="date"
+            placeholder="结束日期"
+            style="width: 100%"
+            :picker-options="endPickerOptions"
+          />
+        </el-form-item>
+      </el-col>
+    </el-form-item>
+
+    <el-form-item label="本周代办">
+      <el-input
+        v-model="form.todo_items"
+        type="textarea"
+        :rows="3"
+        placeholder="输入本周需要完成的任务清单"
+      />
+    </el-form-item>
+
+    <el-form-item label="注意事项">
+      <el-input
+        v-model="form.notes"
+        type="textarea"
+        :rows="3"
+        placeholder="输入本周需要注意的事项"
+      />
+    </el-form-item>
+
+    <el-form-item label="卫生安排">
+      <el-input
+        v-model="form.cleaning_schedule"
+        type="textarea"
+        :rows="2"
+        placeholder="输入本周卫生值班安排"
+      />
+    </el-form-item>
+
+    <el-form-item label="状态" prop="status">
+      <el-select v-model="form.status" placeholder="选择状态">
+        <el-option
+          v-for="item in statusOptions"
+          :key="item.value"
+          :label="item.label"
+          :value="item.value"
+        />
+      </el-select>
+    </el-form-item>
+
+    <el-form-item class="form-actions">
+      <el-button type="primary" @click="submitForm">保存</el-button>
+      <el-button @click="cancel">取消</el-button>
+    </el-form-item>
+  </el-form>
+</template>
+
+<script>
+import { getauth, postauth, putauth } from '@/boot/axios_request'
+
+export default {
+  name: 'DutyEditForm',
+
+  props: {
+    // 初始轮值数据(编辑模式传入)
+    duty: {
+      type: Object,
+      default: () => ({})
+    }
+  },
+
+  data() {
+    // 结束日期不能早于开始日期
+    const validateEndDate = (rule, value, callback) => {
+      if (!value) {
+        callback(new Error('请选择结束日期'))
+      } else if (this.form.start_date && value < this.form.start_date) {
+        callback(new Error('结束日期不能早于开始日期'))
+      } else {
+        callback()
+      }
+    }
+
+    return {
+      users: [],
+      loading: false,
+      form: {
+        duty_id: null,
+        user_id: null,
+        start_date: null,
+        end_date: null,
+        todo_items: '',
+        notes: '',
+        cleaning_schedule: '',
+        status: 'ACT'
+      },
+      rules: {
+        user_id: [
+          { required: true, message: '请选择轮值同学', trigger: 'blur' }
+        ],
+        start_date: [
+          { required: true, message: '请选择开始日期', trigger: 'change' }
+        ],
+        end_date: [
+          { required: true, validator: validateEndDate, trigger: 'change' }
+        ],
+        status: [{ required: true, message: '请选择状态', trigger: 'change' }]
+      },
+      statusOptions: [
+        { value: 'ACT', label: '进行中' },
+        { value: 'UP', label: '即将开始' },
+        { value: 'COM', label: '已完成' }
+      ]
+    }
+  },
+
+  computed: {
+    dutyId() {
+      return this.duty?.duty_id
+    },
+
+    startPickerOptions() {
+      return {
+        disabledDate: (time) => {
+          if (this.form.end_date) {
+            return time.getTime() > this.form.end_date
+          }
+          return false
+        }
+      }
+    },
+
+    endPickerOptions() {
+      return {
+        disabledDate: (time) => {
+          if (this.form.start_date) {
+            return time.getTime() < this.form.start_date - 24 * 60 * 60 * 1000
+          }
+          return false
+        }
+      }
+    }
+  },
+
+  watch: {
+    duty: {
+      immediate: true,
+      deep: true,
+      handler(newDuty) {
+        if (newDuty && Object.keys(newDuty).length > 0) {
+          this.populateForm(newDuty)
+        } else {
+          this.resetForm()
+        }
+      }
+    }
+  },
+
+  async created() {
+    await this.fetchUsers()
+  },
+
+  methods: {
+    populateForm(duty) {
+      this.form = {
+        duty_id: duty.duty_id,
+        user_id: duty.user.user_id,
+        start_date: duty.start_date,
+        end_date: duty.end_date,
+        todo_items: duty.todo_items,
+        notes: duty.notes,
+        cleaning_schedule: duty.cleaning_schedule,
+        status: duty.status
+      }
+    },
+
+    resetForm() {
+      this.form = {
+        duty_id: null,
+        user_id: null,
+        start_date: null,
+        end_date: null,
+        todo_items: '',
+        notes: '',
+        cleaning_schedule: '',
+        status: 'ACT'
+      }
+      this.$nextTick(() => {
+        if (this.$refs.dutyForm) {
+          this.$refs.dutyForm.clearValidate()
+        }
+      })
+    },
+
+    async fetchUsers() {
+      try {
+        this.loading = true
+        const response = await getauth('user/profile/')
+        this.users = response.results.map((user) => ({
+          user_id: user.user_id,
+          name: user.name
+        }))
+      } catch (error) {
+        this.$message.error('获取用户列表失败')
+        console.error(error)
+      } finally {
+        this.loading = false
+      }
+    },
+
+    submitForm() {
+      this.$refs.dutyForm.validate(async(valid) => {
+        if (!valid) {
+          return false
+        }
+
+        try {
+          this.loading = true
+
+          // 准备API参数
+          const formData = new FormData()
+          Object.entries(this.form).forEach(([key, value]) => {
+            if (value !== null) formData.append(key, value)
+          })
+
+          // 更新或创建
+          if (this.form.duty_id) {
+            await putauth(`duty-roster/${this.form.duty_id}/`, formData)
+          } else {
+            await postauth('duty-roster/', formData)
+          }
+
+          this.$emit('success')
+          this.$message.success('轮值信息保存成功')
+        } catch (error) {
+          console.error(error)
+          const message = error.response?.data?.detail || '保存失败,请重试'
+          this.$message.error(message)
+        } finally {
+          this.loading = false
+        }
+      })
+    },
+
+    cancel() {
+      this.$emit('cancel')
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.duty-edit-form {
+  padding: 20px;
+
+  .el-form-item {
+    margin-bottom: 22px;
+
+    &.is-required .el-form-item__label::before {
+      content: "*";
+      color: #f56c6c;
+      margin-right: 4px;
+    }
+  }
+
+  .form-actions {
+    margin-top: 30px;
+    text-align: center;
+  }
+}
+</style>

+ 62 - 0
templates/src/components/Guide/Steps.js

@@ -0,0 +1,62 @@
+const steps = [
+  {
+    element: '#avatar-img',
+    popover: {
+      title: '个人中心', // Avatar → 个人中心
+      description: '个人信息,包括头像、昵称、邮箱等', // Personal information, including avatar, nickname, email, etc.
+      position: 'left'
+    }
+  },
+  {
+    element: '#hamburger-container',
+    popover: {
+      title: '菜单', // Hamburger → 汉堡菜单
+      description: '打开和关闭侧边栏', // Open && Close sidebar → 打开和关闭侧边栏
+      position: 'bottom'
+    }
+  },
+  {
+    element: '#breadcrumb-container',
+    popover: {
+      title: '导航', // Breadcrumb → 面包屑导航
+      description: '指示当前页面位置', // Indicate the current page location → 指示当前页面位置
+      position: 'bottom'
+    }
+  },
+  {
+    element: '#header-search',
+    popover: {
+      title: '页面搜索', // Page Search → 页面搜索
+      description: '页面搜索,快速导航', // Page search, quick navigation → 页面搜索,快速导航
+      position: 'left'
+    }
+  },
+  {
+    element: '#screenfull',
+    popover: {
+      title: '全屏显示', // Screenfull → 全屏显示
+      description: '将页面设置为全屏', // Set the page into fullscreen → 将页面设置为全屏
+      position: 'left'
+    }
+  },
+  // {
+  //   element: '#size-select',
+  //   popover: {
+  //     title: '切换尺寸', // Switch Size → 切换尺寸
+  //     description: '切换系统尺寸', // Switch the system size → 切换系统尺寸
+  //     position: 'left'
+  //   }
+  // },
+
+  {
+    element: '#tags-view-container',
+    popover: {
+      title: '标签视图', // Tags view → 标签视图
+      description: '您访问过的页面历史记录', // The history of the page you visited → 您访问过的页面历史记录
+      position: 'bottom'
+    },
+    padding: 0
+  }
+]
+
+export default steps

+ 47 - 0
templates/src/components/Guide/index.vue

@@ -0,0 +1,47 @@
+<template>
+  <div>
+    <el-button
+      icon="el-icon-question"
+      @click.prevent.stop="guide"
+    />
+  </div>
+</template>
+
+<script>
+import Driver from 'driver.js'
+import 'driver.js/dist/driver.min.css'
+import steps from './Steps'
+
+export default {
+  name: 'Guide',
+  data() {
+    return {
+      driver: null
+    }
+  },
+  mounted() {
+    // 正确的配置方式:在创建实例时传入选项
+    this.driver = new Driver({
+      doneBtnText: '完成',
+      closeBtnText: '关闭',
+      nextBtnText: '下一步',
+      prevBtnText: '上一步'
+
+    })
+  },
+  methods: {
+    guide() {
+      this.driver.defineSteps(steps)
+      this.driver.start()
+    }
+  }
+}
+</script>
+
+<style scoped>
+
+.el-button {
+  font-size: 20px;
+  padding: 10px;
+}
+</style>

+ 61 - 0
templates/src/components/OnlyOfficeEditor/index.vue

@@ -0,0 +1,61 @@
+<template>
+  <div class="onlyoffice-editor">
+    <iframe
+      :src="editorUrl"
+      frameborder="0"
+      allowfullscreen
+      class="editor-frame"
+    />
+  </div>
+</template>
+
+<script>
+export default {
+  props: {
+    documentId: {
+      type: String,
+      required: true
+    },
+    initialContent: {
+      type: String,
+      default: ''
+    }
+  },
+
+  computed: {
+    editorUrl() {
+      // 实际项目中从后端API获取文档URL
+      return `/onlyoffice/${this.documentId}`
+    }
+  },
+
+  methods: {
+    saveDocument() {
+      // 使用OnlyOffice API保存文档
+      // 实际实现依赖于OnlyOffice集成方案
+
+      // 保存成功后触发事件
+      this.$emit('save', this.getContent())
+    },
+
+    getContent() {
+      // 获取文档内容(模拟)
+      return '文档内容已更新...'
+    }
+  }
+}
+</script>
+
+<style lang="scss">
+.onlyoffice-editor {
+  height: 70vh;
+  width: 100%;
+
+  .editor-frame {
+    width: 100%;
+    height: 100%;
+    border: 1px solid #e6e6e6;
+    border-radius: 4px;
+  }
+}
+</style>

+ 30 - 25
templates/src/layout/components/Navbar.vue

@@ -1,27 +1,38 @@
 <!-- 顶部窗口导航栏 -->
 <!-- 顶部窗口导航栏 -->
 <template>
 <template>
   <div class="navbar">
   <div class="navbar">
-    <hamburger id="hamburger-container" :is-active="sidebar.opened" class="hamburger-container" @toggleClick="toggleSideBar" />
+    <hamburger
+      id="hamburger-container"
+      :is-active="sidebar.opened"
+      class="hamburger-container"
+      @toggleClick="toggleSideBar"
+    />
 
 
     <breadcrumb id="breadcrumb-container" class="breadcrumb-container" />
     <breadcrumb id="breadcrumb-container" class="breadcrumb-container" />
 
 
     <div class="right-menu">
     <div class="right-menu">
-      <template v-if="device!=='mobile'">
+      <template v-if="device !== 'mobile'">
         <search id="header-search" class="right-menu-item" />
         <search id="header-search" class="right-menu-item" />
 
 
-        <error-log class="errLog-container right-menu-item hover-effect" />
-
         <screenfull id="screenfull" class="right-menu-item hover-effect" />
         <screenfull id="screenfull" class="right-menu-item hover-effect" />
 
 
-        <el-tooltip content="Global Size" effect="dark" placement="bottom">
-          <size-select id="size-select" class="right-menu-item hover-effect" />
-        </el-tooltip>
+        <Guide id="guide" class="right-menu-item" />
 
 
+        <!-- <el-tooltip content="Global Size" effect="dark" placement="bottom">
+          <size-select id="size-select" class="right-menu-item hover-effect" />
+        </el-tooltip> -->
       </template>
       </template>
 
 
-      <el-dropdown class="avatar-container right-menu-item hover-effect" trigger="click">
+      <el-dropdown
+        class="avatar-container right-menu-item hover-effect"
+        trigger="click"
+      >
         <div class="avatar-wrapper">
         <div class="avatar-wrapper">
-          <img :src="avatar+'?imageView2/1/w/80/h/80'" class="user-avatar">
+          <img
+            id="avatar-img"
+            :src="avatar + '?imageView2/1/w/80/h/80'"
+            class="user-avatar"
+          >
           <i class="el-icon-caret-bottom" />
           <i class="el-icon-caret-bottom" />
         </div>
         </div>
         <el-dropdown-menu slot="dropdown">
         <el-dropdown-menu slot="dropdown">
@@ -32,7 +43,7 @@
             <el-dropdown-item>首页</el-dropdown-item>
             <el-dropdown-item>首页</el-dropdown-item>
           </router-link>
           </router-link>
           <el-dropdown-item divided @click.native="logout">
           <el-dropdown-item divided @click.native="logout">
-            <span style="display:block;">退出登录</span>
+            <span style="display: block">退出登录</span>
           </el-dropdown-item>
           </el-dropdown-item>
         </el-dropdown-menu>
         </el-dropdown-menu>
       </el-dropdown>
       </el-dropdown>
@@ -44,26 +55,20 @@
 import { mapGetters } from 'vuex'
 import { mapGetters } from 'vuex'
 import Breadcrumb from '@/components/Breadcrumb'
 import Breadcrumb from '@/components/Breadcrumb'
 import Hamburger from '@/components/Hamburger'
 import Hamburger from '@/components/Hamburger'
-import ErrorLog from '@/components/ErrorLog'
+import Guide from '@/components/Guide'
 import Screenfull from '@/components/Screenfull'
 import Screenfull from '@/components/Screenfull'
-import SizeSelect from '@/components/SizeSelect'
 import Search from '@/components/HeaderSearch'
 import Search from '@/components/HeaderSearch'
 
 
 export default {
 export default {
   components: {
   components: {
     Breadcrumb,
     Breadcrumb,
     Hamburger,
     Hamburger,
-    ErrorLog,
+    Guide,
     Screenfull,
     Screenfull,
-    SizeSelect,
     Search
     Search
   },
   },
   computed: {
   computed: {
-    ...mapGetters([
-      'sidebar',
-      'avatar',
-      'device'
-    ])
+    ...mapGetters(['sidebar', 'avatar', 'device'])
   },
   },
   methods: {
   methods: {
     toggleSideBar() {
     toggleSideBar() {
@@ -83,18 +88,18 @@ export default {
   overflow: hidden;
   overflow: hidden;
   position: relative;
   position: relative;
   background: #fff;
   background: #fff;
-  box-shadow: 0 1px 4px rgba(0,21,41,.08);
+  box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
 
 
   .hamburger-container {
   .hamburger-container {
     line-height: 46px;
     line-height: 46px;
     height: 100%;
     height: 100%;
     float: left;
     float: left;
     cursor: pointer;
     cursor: pointer;
-    transition: background .3s;
-    -webkit-tap-highlight-color:transparent;
+    transition: background 0.3s;
+    -webkit-tap-highlight-color: transparent;
 
 
     &:hover {
     &:hover {
-      background: rgba(0, 0, 0, .025)
+      background: rgba(0, 0, 0, 0.025);
     }
     }
   }
   }
 
 
@@ -126,10 +131,10 @@ export default {
 
 
       &.hover-effect {
       &.hover-effect {
         cursor: pointer;
         cursor: pointer;
-        transition: background .3s;
+        transition: background 0.3s;
 
 
         &:hover {
         &:hover {
-          background: rgba(0, 0, 0, .025)
+          background: rgba(0, 0, 0, 0.025);
         }
         }
       }
       }
     }
     }

+ 1 - 1
templates/src/layout/components/Settings/index.vue

@@ -14,7 +14,7 @@
       </div>
       </div>
 
 
       <div class="drawer-item">
       <div class="drawer-item">
-        <span>固定头部</span>
+        <span>固定主页</span>
         <el-switch v-model="fixedHeader" class="drawer-switch" />
         <el-switch v-model="fixedHeader" class="drawer-switch" />
       </div>
       </div>
 
 

+ 1 - 0
templates/src/layout/index.vue

@@ -4,6 +4,7 @@
     <sidebar class="sidebar-container" />
     <sidebar class="sidebar-container" />
     <div :class="{hasTagsView:needTagsView}" class="main-container">
     <div :class="{hasTagsView:needTagsView}" class="main-container">
       <div :class="{'fixed-header':fixedHeader}">
       <div :class="{'fixed-header':fixedHeader}">
+        <!-- 顶部功能栏 -->
         <navbar />
         <navbar />
         <tags-view v-if="needTagsView" />
         <tags-view v-if="needTagsView" />
       </div>
       </div>

+ 66 - 25
templates/src/router/index.js

@@ -57,16 +57,56 @@ export const constantRoutes = [
       }
       }
     ]
     ]
   },
   },
+  {
+    path: '/groupMeeting',
+    component: Layout,
+    redirect: '/groupMeeting/index',
+    children: [
+      {
+        path: 'index',
+        component: () => import('@/views/groupMeeting/index'),
+        name: 'GroupMeeting',
+        meta: {
+          title: '组会管理',
+          icon: 'guide',
+          noCache: true
+        }
+      }
+    ]
+  },
+  {
+    path: '/invoiceRecord',
+    component: Layout,
+    redirect: '/invoiceRecord/index',
+    children: [
+      {
+        path: 'index',
+        component: () => import('@/views/invoiceRecord/index'),
+        name: 'Guide',
+        meta: {
+          title: '发票管理',
+          icon: 'skill',
+          noCache: true
+        }
+      }
+    ]
+  },
+
   {
   {
     path: '/guide',
     path: '/guide',
     component: Layout,
     component: Layout,
     redirect: '/guide/index',
     redirect: '/guide/index',
+    hidden: true,
     children: [
     children: [
       {
       {
         path: 'index',
         path: 'index',
         component: () => import('@/views/guide/index'),
         component: () => import('@/views/guide/index'),
         name: 'Guide',
         name: 'Guide',
-        meta: { title: '引导', icon: 'guide', noCache: true }
+        meta: {
+          title: '引导',
+          icon: 'guide',
+          noCache: true
+        }
       }
       }
     ]
     ]
   },
   },
@@ -96,7 +136,7 @@ export const asyncRoutes = [
     meta: {
     meta: {
       title: '权限管理',
       title: '权限管理',
       icon: 'lock',
       icon: 'lock',
-      roles: ['admin', 'editor']
+      roles: ['admin', 'developer']
     },
     },
     children: [
     children: [
       {
       {
@@ -105,7 +145,7 @@ export const asyncRoutes = [
         name: 'PagePermission',
         name: 'PagePermission',
         meta: {
         meta: {
           title: '页面权限',
           title: '页面权限',
-          roles: ['admin']
+          roles: ['admin', 'developer']
         }
         }
       },
       },
       {
       {
@@ -113,7 +153,8 @@ export const asyncRoutes = [
         component: () => import('@/views/permission/directive'),
         component: () => import('@/views/permission/directive'),
         name: 'DirectivePermission',
         name: 'DirectivePermission',
         meta: {
         meta: {
-          title: '指令权限'
+          title: '指令权限',
+          roles: ['admin', 'developer']
         }
         }
       },
       },
       {
       {
@@ -135,7 +176,13 @@ export const asyncRoutes = [
         path: 'index',
         path: 'index',
         component: () => import('@/views/icons/index'),
         component: () => import('@/views/icons/index'),
         name: 'Icons',
         name: 'Icons',
-        meta: { title: '图标库', icon: 'icon', noCache: true }
+
+        meta: {
+          title: '图标库',
+          icon: 'icon',
+          roles: ['developer'],
+          noCache: true
+        }
       }
       }
     ]
     ]
   },
   },
@@ -150,6 +197,7 @@ export const asyncRoutes = [
     name: 'Example',
     name: 'Example',
     meta: {
     meta: {
       title: '示例中心',
       title: '示例中心',
+      roles: ['developer'],
       icon: 'el-icon-s-help'
       icon: 'el-icon-s-help'
     },
     },
     children: [
     children: [
@@ -182,7 +230,7 @@ export const asyncRoutes = [
         path: 'index',
         path: 'index',
         component: () => import('@/views/tab/index'),
         component: () => import('@/views/tab/index'),
         name: 'Tab',
         name: 'Tab',
-        meta: { title: '标签页管理', icon: 'tab' }
+        meta: { title: '标签页管理', roles: ['developer'], icon: 'tab' }
       }
       }
     ]
     ]
   },
   },
@@ -193,6 +241,7 @@ export const asyncRoutes = [
     name: 'ErrorPages',
     name: 'ErrorPages',
     meta: {
     meta: {
       title: '错误页面',
       title: '错误页面',
+      roles: ['developer'],
       icon: '404'
       icon: '404'
     },
     },
     children: [
     children: [
@@ -218,7 +267,7 @@ export const asyncRoutes = [
         path: 'log',
         path: 'log',
         component: () => import('@/views/error-log/index'),
         component: () => import('@/views/error-log/index'),
         name: 'ErrorLog',
         name: 'ErrorLog',
-        meta: { title: '错误日志', icon: 'bug' }
+        meta: { title: '错误日志', roles: ['developer'], icon: 'bug' }
       }
       }
     ]
     ]
   },
   },
@@ -229,6 +278,7 @@ export const asyncRoutes = [
     name: 'Excel',
     name: 'Excel',
     meta: {
     meta: {
       title: 'Excel操作',
       title: 'Excel操作',
+      roles: ['developer'],
       icon: 'excel'
       icon: 'excel'
     },
     },
     children: [
     children: [
@@ -264,7 +314,7 @@ export const asyncRoutes = [
     redirect: '/zip/download',
     redirect: '/zip/download',
     alwaysShow: true,
     alwaysShow: true,
     name: 'Zip',
     name: 'Zip',
-    meta: { title: '压缩管理', icon: 'zip' },
+    meta: { title: '压缩管理', roles: ['developer'], icon: 'zip' },
     children: [
     children: [
       {
       {
         path: 'download',
         path: 'download',
@@ -283,7 +333,7 @@ export const asyncRoutes = [
         path: 'index',
         path: 'index',
         component: () => import('@/views/pdf/index'),
         component: () => import('@/views/pdf/index'),
         name: 'PDF',
         name: 'PDF',
-        meta: { title: 'PDF操作', icon: 'pdf' }
+        meta: { title: 'PDF操作', roles: ['developer'], icon: 'pdf' }
       }
       }
     ]
     ]
   },
   },
@@ -300,7 +350,7 @@ export const asyncRoutes = [
         path: 'index',
         path: 'index',
         component: () => import('@/views/theme/index'),
         component: () => import('@/views/theme/index'),
         name: 'Theme',
         name: 'Theme',
-        meta: { title: '主题设置', icon: 'theme' }
+        meta: { title: '主题设置', roles: ['developer'], icon: 'theme' }
       }
       }
     ]
     ]
   },
   },
@@ -312,27 +362,18 @@ export const asyncRoutes = [
         path: 'index',
         path: 'index',
         component: () => import('@/views/clipboard/index'),
         component: () => import('@/views/clipboard/index'),
         name: 'ClipboardDemo',
         name: 'ClipboardDemo',
-        meta: { title: '剪贴板', icon: 'clipboard' }
-      }
-    ]
-  },
-  {
-    path: 'external-link',
-    component: Layout,
-    children: [
-      {
-        path: 'https://github.com/PanJiaChen/vue-element-admin',
-        meta: { title: '外部链接', icon: 'link' }
+        meta: { title: '剪贴板', roles: ['developer'], icon: 'clipboard' }
       }
       }
     ]
     ]
   },
   },
   { path: '*', redirect: '/404', hidden: true }
   { path: '*', redirect: '/404', hidden: true }
 ]
 ]
 
 
-const createRouter = () => new Router({
-  scrollBehavior: () => ({ y: 0 }),
-  routes: constantRoutes
-})
+const createRouter = () =>
+  new Router({
+    scrollBehavior: () => ({ y: 0 }),
+    routes: constantRoutes
+  })
 
 
 const router = createRouter()
 const router = createRouter()
 
 

+ 2 - 1
templates/src/router/modules/charts.js

@@ -8,7 +8,8 @@ const chartsRouter = {
   redirect: 'noRedirect',
   redirect: 'noRedirect',
   name: 'Charts',
   name: 'Charts',
   meta: {
   meta: {
-    title: '图表管理', // 翻译为中文
+    title: '图表管理',
+    roles: ['developer'],
     icon: 'chart'
     icon: 'chart'
   },
   },
   children: [
   children: [

+ 1 - 0
templates/src/router/modules/components.js

@@ -9,6 +9,7 @@ const componentsRouter = {
   name: 'ComponentDemo',
   name: 'ComponentDemo',
   meta: {
   meta: {
     title: '组件管理',
     title: '组件管理',
+    roles: ['developer'],
     icon: 'component'
     icon: 'component'
   },
   },
   children: [
   children: [

+ 1 - 0
templates/src/router/modules/nested.js

@@ -9,6 +9,7 @@ const nestedRouter = {
   name: 'Nested',
   name: 'Nested',
   meta: {
   meta: {
     title: '嵌套路由',
     title: '嵌套路由',
+    roles: ['developer'],
     icon: 'nested'
     icon: 'nested'
   },
   },
   children: [
   children: [

+ 1 - 0
templates/src/router/modules/table.js

@@ -9,6 +9,7 @@ const tableRouter = {
   name: 'Table',
   name: 'Table',
   meta: {
   meta: {
     title: '表格',
     title: '表格',
+    roles: ['developer'],
     icon: 'table'
     icon: 'table'
   },
   },
   children: [
   children: [

+ 9 - 9
templates/src/store/modules/permission.js

@@ -1,27 +1,27 @@
 import { asyncRoutes, constantRoutes } from '@/router'
 import { asyncRoutes, constantRoutes } from '@/router'
 
 
 /**
 /**
- * Use meta.role to determine if the current user has permission
- * @param roles
- * @param route
+ * 使用 meta.role 确定当前用户是否有权限
+ * @param roles 用户角色列表
+ * @param route 当前路由对象
  */
  */
 function hasPermission(roles, route) {
 function hasPermission(roles, route) {
   if (route.meta && route.meta.roles) {
   if (route.meta && route.meta.roles) {
-    return roles.some(role => route.meta.roles.includes(role))
+    return roles.some((role) => route.meta.roles.includes(role))
   } else {
   } else {
     return true
     return true
   }
   }
 }
 }
 
 
 /**
 /**
- * Filter asynchronous routing tables by recursion
- * @param routes asyncRoutes
- * @param roles
+ * 通过递归过滤异步路由表
+ * @param routes 异步路由表
+ * @param roles 用户角色列表
  */
  */
 export function filterAsyncRoutes(routes, roles) {
 export function filterAsyncRoutes(routes, roles) {
   const res = []
   const res = []
 
 
-  routes.forEach(route => {
+  routes.forEach((route) => {
     const tmp = { ...route }
     const tmp = { ...route }
     if (hasPermission(roles, tmp)) {
     if (hasPermission(roles, tmp)) {
       if (tmp.children) {
       if (tmp.children) {
@@ -48,7 +48,7 @@ const mutations = {
 
 
 const actions = {
 const actions = {
   generateRoutes({ commit }, roles) {
   generateRoutes({ commit }, roles) {
-    return new Promise(resolve => {
+    return new Promise((resolve) => {
       let accessedRoutes
       let accessedRoutes
       if (roles.includes('admin')) {
       if (roles.includes('admin')) {
         accessedRoutes = asyncRoutes || []
         accessedRoutes = asyncRoutes || []

+ 26 - 9
templates/src/views/dashboard/admin/index.vue

@@ -1,10 +1,8 @@
 <template>
 <template>
   <div class="dashboard-editor-container">
   <div class="dashboard-editor-container">
-    <github-corner class="github-corner" />
-
     <panel-group @handleSetLineChartData="handleSetLineChartData" />
     <panel-group @handleSetLineChartData="handleSetLineChartData" />
 
 
-    <el-row style="background:#fff;padding:16px 16px 0;margin-bottom:32px;">
+    <el-row style="background: #fff; padding: 16px 16px 0; margin-bottom: 32px">
       <line-chart :chart-data="lineChartData" />
       <line-chart :chart-data="lineChartData" />
     </el-row>
     </el-row>
 
 
@@ -27,13 +25,34 @@
     </el-row>
     </el-row>
 
 
     <el-row :gutter="8">
     <el-row :gutter="8">
-      <el-col :xs="{span: 24}" :sm="{span: 24}" :md="{span: 24}" :lg="{span: 12}" :xl="{span: 12}" style="padding-right:8px;margin-bottom:30px;">
+      <el-col
+        :xs="{ span: 24 }"
+        :sm="{ span: 24 }"
+        :md="{ span: 24 }"
+        :lg="{ span: 12 }"
+        :xl="{ span: 12 }"
+        style="padding-right: 8px; margin-bottom: 30px"
+      >
         <transaction-table />
         <transaction-table />
       </el-col>
       </el-col>
-      <el-col :xs="{span: 24}" :sm="{span: 12}" :md="{span: 12}" :lg="{span: 6}" :xl="{span: 6}" style="margin-bottom:30px;">
+      <el-col
+        :xs="{ span: 24 }"
+        :sm="{ span: 12 }"
+        :md="{ span: 12 }"
+        :lg="{ span: 6 }"
+        :xl="{ span: 6 }"
+        style="margin-bottom: 30px"
+      >
         <todo-list />
         <todo-list />
       </el-col>
       </el-col>
-      <el-col :xs="{span: 24}" :sm="{span: 12}" :md="{span: 12}" :lg="{span: 6}" :xl="{span: 6}" style="margin-bottom:30px;">
+      <el-col
+        :xs="{ span: 24 }"
+        :sm="{ span: 12 }"
+        :md="{ span: 12 }"
+        :lg="{ span: 6 }"
+        :xl="{ span: 6 }"
+        style="margin-bottom: 30px"
+      >
         <box-card />
         <box-card />
       </el-col>
       </el-col>
     </el-row>
     </el-row>
@@ -41,7 +60,6 @@
 </template>
 </template>
 
 
 <script>
 <script>
-import GithubCorner from '@/components/GithubCorner'
 import PanelGroup from './components/PanelGroup'
 import PanelGroup from './components/PanelGroup'
 import LineChart from './components/LineChart'
 import LineChart from './components/LineChart'
 import RaddarChart from './components/RaddarChart'
 import RaddarChart from './components/RaddarChart'
@@ -73,7 +91,6 @@ const lineChartData = {
 export default {
 export default {
   name: 'DashboardAdmin',
   name: 'DashboardAdmin',
   components: {
   components: {
-    GithubCorner,
     PanelGroup,
     PanelGroup,
     LineChart,
     LineChart,
     RaddarChart,
     RaddarChart,
@@ -116,7 +133,7 @@ export default {
   }
   }
 }
 }
 
 
-@media (max-width:1024px) {
+@media (max-width: 1024px) {
   .chart-wrapper {
   .chart-wrapper {
     padding: 8px;
     padding: 8px;
   }
   }

+ 102 - 0
templates/src/views/dashboard/editor/components/BarChart.vue

@@ -0,0 +1,102 @@
+<template>
+  <div :class="className" :style="{height:height,width:width}" />
+</template>
+
+<script>
+import echarts from 'echarts'
+require('echarts/theme/macarons') // echarts theme
+import resize from './mixins/resize'
+
+const animationDuration = 6000
+
+export default {
+  mixins: [resize],
+  props: {
+    className: {
+      type: String,
+      default: 'chart'
+    },
+    width: {
+      type: String,
+      default: '100%'
+    },
+    height: {
+      type: String,
+      default: '300px'
+    }
+  },
+  data() {
+    return {
+      chart: null
+    }
+  },
+  mounted() {
+    this.$nextTick(() => {
+      this.initChart()
+    })
+  },
+  beforeDestroy() {
+    if (!this.chart) {
+      return
+    }
+    this.chart.dispose()
+    this.chart = null
+  },
+  methods: {
+    initChart() {
+      this.chart = echarts.init(this.$el, 'macarons')
+
+      this.chart.setOption({
+        tooltip: {
+          trigger: 'axis',
+          axisPointer: { // 坐标轴指示器,坐标轴触发有效
+            type: 'shadow' // 默认为直线,可选为:'line' | 'shadow'
+          }
+        },
+        grid: {
+          top: 10,
+          left: '2%',
+          right: '2%',
+          bottom: '3%',
+          containLabel: true
+        },
+        xAxis: [{
+          type: 'category',
+          data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
+          axisTick: {
+            alignWithLabel: true
+          }
+        }],
+        yAxis: [{
+          type: 'value',
+          axisTick: {
+            show: false
+          }
+        }],
+        series: [{
+          name: 'pageA',
+          type: 'bar',
+          stack: 'vistors',
+          barWidth: '60%',
+          data: [79, 52, 200, 334, 390, 330, 220],
+          animationDuration
+        }, {
+          name: 'pageB',
+          type: 'bar',
+          stack: 'vistors',
+          barWidth: '60%',
+          data: [80, 52, 200, 334, 390, 330, 220],
+          animationDuration
+        }, {
+          name: 'pageC',
+          type: 'bar',
+          stack: 'vistors',
+          barWidth: '60%',
+          data: [30, 52, 200, 334, 390, 330, 220],
+          animationDuration
+        }]
+      })
+    }
+  }
+}
+</script>

+ 118 - 0
templates/src/views/dashboard/editor/components/BoxCard.vue

@@ -0,0 +1,118 @@
+<template>
+  <el-card class="box-card-component" style="margin-left:8px;">
+    <div slot="header" class="box-card-header">
+      <img src="https://wpimg.wallstcn.com/e7d23d71-cf19-4b90-a1cc-f56af8c0903d.png">
+    </div>
+    <div style="position:relative;">
+      <pan-thumb :image="avatar" class="panThumb" />
+      <mallki class-name="mallki-text" text="vue-element-admin" />
+      <div style="padding-top:35px;" class="progress-item">
+        <span>Vue</span>
+        <el-progress :percentage="70" />
+      </div>
+      <div class="progress-item">
+        <span>JavaScript</span>
+        <el-progress :percentage="18" />
+      </div>
+      <div class="progress-item">
+        <span>CSS</span>
+        <el-progress :percentage="12" />
+      </div>
+      <div class="progress-item">
+        <span>ESLint</span>
+        <el-progress :percentage="100" status="success" />
+      </div>
+    </div>
+  </el-card>
+</template>
+
+<script>
+import { mapGetters } from 'vuex'
+import PanThumb from '@/components/PanThumb'
+import Mallki from '@/components/TextHoverEffect/Mallki'
+
+export default {
+  components: { PanThumb, Mallki },
+
+  filters: {
+    statusFilter(status) {
+      const statusMap = {
+        success: 'success',
+        pending: 'danger'
+      }
+      return statusMap[status]
+    }
+  },
+  data() {
+    return {
+      statisticsData: {
+        article_count: 1024,
+        pageviews_count: 1024
+      }
+    }
+  },
+  computed: {
+    ...mapGetters([
+      'name',
+      'avatar',
+      'roles'
+    ])
+  }
+}
+</script>
+
+<style lang="scss" >
+.box-card-component{
+  .el-card__header {
+    padding: 0px!important;
+  }
+}
+</style>
+<style lang="scss" scoped>
+.box-card-component {
+  .box-card-header {
+    position: relative;
+    height: 220px;
+    img {
+      width: 100%;
+      height: 100%;
+      transition: all 0.2s linear;
+      &:hover {
+        transform: scale(1.1, 1.1);
+        filter: contrast(130%);
+      }
+    }
+  }
+  .mallki-text {
+    position: absolute;
+    top: 0px;
+    right: 0px;
+    font-size: 20px;
+    font-weight: bold;
+  }
+  .panThumb {
+    z-index: 100;
+    height: 70px!important;
+    width: 70px!important;
+    position: absolute!important;
+    top: -45px;
+    left: 0px;
+    border: 5px solid #ffffff;
+    background-color: #fff;
+    margin: auto;
+    box-shadow: none!important;
+    ::v-deep .pan-info {
+      box-shadow: none!important;
+    }
+  }
+  .progress-item {
+    margin-bottom: 10px;
+    font-size: 14px;
+  }
+  @media only screen and (max-width: 1510px){
+    .mallki-text{
+      display: none;
+    }
+  }
+}
+</style>

+ 135 - 0
templates/src/views/dashboard/editor/components/LineChart.vue

@@ -0,0 +1,135 @@
+<template>
+  <div :class="className" :style="{height:height,width:width}" />
+</template>
+
+<script>
+import echarts from 'echarts'
+require('echarts/theme/macarons') // echarts theme
+import resize from './mixins/resize'
+
+export default {
+  mixins: [resize],
+  props: {
+    className: {
+      type: String,
+      default: 'chart'
+    },
+    width: {
+      type: String,
+      default: '100%'
+    },
+    height: {
+      type: String,
+      default: '350px'
+    },
+    autoResize: {
+      type: Boolean,
+      default: true
+    },
+    chartData: {
+      type: Object,
+      required: true
+    }
+  },
+  data() {
+    return {
+      chart: null
+    }
+  },
+  watch: {
+    chartData: {
+      deep: true,
+      handler(val) {
+        this.setOptions(val)
+      }
+    }
+  },
+  mounted() {
+    this.$nextTick(() => {
+      this.initChart()
+    })
+  },
+  beforeDestroy() {
+    if (!this.chart) {
+      return
+    }
+    this.chart.dispose()
+    this.chart = null
+  },
+  methods: {
+    initChart() {
+      this.chart = echarts.init(this.$el, 'macarons')
+      this.setOptions(this.chartData)
+    },
+    setOptions({ expectedData, actualData } = {}) {
+      this.chart.setOption({
+        xAxis: {
+          data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
+          boundaryGap: false,
+          axisTick: {
+            show: false
+          }
+        },
+        grid: {
+          left: 10,
+          right: 10,
+          bottom: 20,
+          top: 30,
+          containLabel: true
+        },
+        tooltip: {
+          trigger: 'axis',
+          axisPointer: {
+            type: 'cross'
+          },
+          padding: [5, 10]
+        },
+        yAxis: {
+          axisTick: {
+            show: false
+          }
+        },
+        legend: {
+          data: ['expected', 'actual']
+        },
+        series: [{
+          name: 'expected', itemStyle: {
+            normal: {
+              color: '#FF005A',
+              lineStyle: {
+                color: '#FF005A',
+                width: 2
+              }
+            }
+          },
+          smooth: true,
+          type: 'line',
+          data: expectedData,
+          animationDuration: 2800,
+          animationEasing: 'cubicInOut'
+        },
+        {
+          name: 'actual',
+          smooth: true,
+          type: 'line',
+          itemStyle: {
+            normal: {
+              color: '#3888fa',
+              lineStyle: {
+                color: '#3888fa',
+                width: 2
+              },
+              areaStyle: {
+                color: '#f3f8ff'
+              }
+            }
+          },
+          data: actualData,
+          animationDuration: 2800,
+          animationEasing: 'quadraticOut'
+        }]
+      })
+    }
+  }
+}
+</script>

+ 181 - 0
templates/src/views/dashboard/editor/components/PanelGroup.vue

@@ -0,0 +1,181 @@
+<template>
+  <el-row :gutter="40" class="panel-group">
+    <el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
+      <div class="card-panel" @click="handleSetLineChartData('newVisitis')">
+        <div class="card-panel-icon-wrapper icon-people">
+          <svg-icon icon-class="peoples" class-name="card-panel-icon" />
+        </div>
+        <div class="card-panel-description">
+          <div class="card-panel-text">
+            New Visits
+          </div>
+          <count-to :start-val="0" :end-val="102400" :duration="2600" class="card-panel-num" />
+        </div>
+      </div>
+    </el-col>
+    <el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
+      <div class="card-panel" @click="handleSetLineChartData('messages')">
+        <div class="card-panel-icon-wrapper icon-message">
+          <svg-icon icon-class="message" class-name="card-panel-icon" />
+        </div>
+        <div class="card-panel-description">
+          <div class="card-panel-text">
+            Messages
+          </div>
+          <count-to :start-val="0" :end-val="81212" :duration="3000" class="card-panel-num" />
+        </div>
+      </div>
+    </el-col>
+    <el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
+      <div class="card-panel" @click="handleSetLineChartData('purchases')">
+        <div class="card-panel-icon-wrapper icon-money">
+          <svg-icon icon-class="money" class-name="card-panel-icon" />
+        </div>
+        <div class="card-panel-description">
+          <div class="card-panel-text">
+            Purchases
+          </div>
+          <count-to :start-val="0" :end-val="9280" :duration="3200" class="card-panel-num" />
+        </div>
+      </div>
+    </el-col>
+    <el-col :xs="12" :sm="12" :lg="6" class="card-panel-col">
+      <div class="card-panel" @click="handleSetLineChartData('shoppings')">
+        <div class="card-panel-icon-wrapper icon-shopping">
+          <svg-icon icon-class="shopping" class-name="card-panel-icon" />
+        </div>
+        <div class="card-panel-description">
+          <div class="card-panel-text">
+            Shoppings
+          </div>
+          <count-to :start-val="0" :end-val="13600" :duration="3600" class="card-panel-num" />
+        </div>
+      </div>
+    </el-col>
+  </el-row>
+</template>
+
+<script>
+import CountTo from 'vue-count-to'
+
+export default {
+  components: {
+    CountTo
+  },
+  methods: {
+    handleSetLineChartData(type) {
+      this.$emit('handleSetLineChartData', type)
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.panel-group {
+  margin-top: 18px;
+
+  .card-panel-col {
+    margin-bottom: 32px;
+  }
+
+  .card-panel {
+    height: 108px;
+    cursor: pointer;
+    font-size: 12px;
+    position: relative;
+    overflow: hidden;
+    color: #666;
+    background: #fff;
+    box-shadow: 4px 4px 40px rgba(0, 0, 0, .05);
+    border-color: rgba(0, 0, 0, .05);
+
+    &:hover {
+      .card-panel-icon-wrapper {
+        color: #fff;
+      }
+
+      .icon-people {
+        background: #40c9c6;
+      }
+
+      .icon-message {
+        background: #36a3f7;
+      }
+
+      .icon-money {
+        background: #f4516c;
+      }
+
+      .icon-shopping {
+        background: #34bfa3
+      }
+    }
+
+    .icon-people {
+      color: #40c9c6;
+    }
+
+    .icon-message {
+      color: #36a3f7;
+    }
+
+    .icon-money {
+      color: #f4516c;
+    }
+
+    .icon-shopping {
+      color: #34bfa3
+    }
+
+    .card-panel-icon-wrapper {
+      float: left;
+      margin: 14px 0 0 14px;
+      padding: 16px;
+      transition: all 0.38s ease-out;
+      border-radius: 6px;
+    }
+
+    .card-panel-icon {
+      float: left;
+      font-size: 48px;
+    }
+
+    .card-panel-description {
+      float: right;
+      font-weight: bold;
+      margin: 26px;
+      margin-left: 0px;
+
+      .card-panel-text {
+        line-height: 18px;
+        color: rgba(0, 0, 0, 0.45);
+        font-size: 16px;
+        margin-bottom: 12px;
+      }
+
+      .card-panel-num {
+        font-size: 20px;
+      }
+    }
+  }
+}
+
+@media (max-width:550px) {
+  .card-panel-description {
+    display: none;
+  }
+
+  .card-panel-icon-wrapper {
+    float: none !important;
+    width: 100%;
+    height: 100%;
+    margin: 0 !important;
+
+    .svg-icon {
+      display: block;
+      margin: 14px auto !important;
+      float: none !important;
+    }
+  }
+}
+</style>

+ 79 - 0
templates/src/views/dashboard/editor/components/PieChart.vue

@@ -0,0 +1,79 @@
+<template>
+  <div :class="className" :style="{height:height,width:width}" />
+</template>
+
+<script>
+import echarts from 'echarts'
+require('echarts/theme/macarons') // echarts theme
+import resize from './mixins/resize'
+
+export default {
+  mixins: [resize],
+  props: {
+    className: {
+      type: String,
+      default: 'chart'
+    },
+    width: {
+      type: String,
+      default: '100%'
+    },
+    height: {
+      type: String,
+      default: '300px'
+    }
+  },
+  data() {
+    return {
+      chart: null
+    }
+  },
+  mounted() {
+    this.$nextTick(() => {
+      this.initChart()
+    })
+  },
+  beforeDestroy() {
+    if (!this.chart) {
+      return
+    }
+    this.chart.dispose()
+    this.chart = null
+  },
+  methods: {
+    initChart() {
+      this.chart = echarts.init(this.$el, 'macarons')
+
+      this.chart.setOption({
+        tooltip: {
+          trigger: 'item',
+          formatter: '{a} <br/>{b} : {c} ({d}%)'
+        },
+        legend: {
+          left: 'center',
+          bottom: '10',
+          data: ['Industries', 'Technology', 'Forex', 'Gold', 'Forecasts']
+        },
+        series: [
+          {
+            name: 'WEEKLY WRITE ARTICLES',
+            type: 'pie',
+            roseType: 'radius',
+            radius: [15, 95],
+            center: ['50%', '38%'],
+            data: [
+              { value: 320, name: 'Industries' },
+              { value: 240, name: 'Technology' },
+              { value: 149, name: 'Forex' },
+              { value: 100, name: 'Gold' },
+              { value: 59, name: 'Forecasts' }
+            ],
+            animationEasing: 'cubicInOut',
+            animationDuration: 2600
+          }
+        ]
+      })
+    }
+  }
+}
+</script>

+ 116 - 0
templates/src/views/dashboard/editor/components/RaddarChart.vue

@@ -0,0 +1,116 @@
+<template>
+  <div :class="className" :style="{height:height,width:width}" />
+</template>
+
+<script>
+import echarts from 'echarts'
+require('echarts/theme/macarons') // echarts theme
+import resize from './mixins/resize'
+
+const animationDuration = 3000
+
+export default {
+  mixins: [resize],
+  props: {
+    className: {
+      type: String,
+      default: 'chart'
+    },
+    width: {
+      type: String,
+      default: '100%'
+    },
+    height: {
+      type: String,
+      default: '300px'
+    }
+  },
+  data() {
+    return {
+      chart: null
+    }
+  },
+  mounted() {
+    this.$nextTick(() => {
+      this.initChart()
+    })
+  },
+  beforeDestroy() {
+    if (!this.chart) {
+      return
+    }
+    this.chart.dispose()
+    this.chart = null
+  },
+  methods: {
+    initChart() {
+      this.chart = echarts.init(this.$el, 'macarons')
+
+      this.chart.setOption({
+        tooltip: {
+          trigger: 'axis',
+          axisPointer: { // 坐标轴指示器,坐标轴触发有效
+            type: 'shadow' // 默认为直线,可选为:'line' | 'shadow'
+          }
+        },
+        radar: {
+          radius: '66%',
+          center: ['50%', '42%'],
+          splitNumber: 8,
+          splitArea: {
+            areaStyle: {
+              color: 'rgba(127,95,132,.3)',
+              opacity: 1,
+              shadowBlur: 45,
+              shadowColor: 'rgba(0,0,0,.5)',
+              shadowOffsetX: 0,
+              shadowOffsetY: 15
+            }
+          },
+          indicator: [
+            { name: 'Sales', max: 10000 },
+            { name: 'Administration', max: 20000 },
+            { name: 'Information Technology', max: 20000 },
+            { name: 'Customer Support', max: 20000 },
+            { name: 'Development', max: 20000 },
+            { name: 'Marketing', max: 20000 }
+          ]
+        },
+        legend: {
+          left: 'center',
+          bottom: '10',
+          data: ['Allocated Budget', 'Expected Spending', 'Actual Spending']
+        },
+        series: [{
+          type: 'radar',
+          symbolSize: 0,
+          areaStyle: {
+            normal: {
+              shadowBlur: 13,
+              shadowColor: 'rgba(0,0,0,.2)',
+              shadowOffsetX: 0,
+              shadowOffsetY: 10,
+              opacity: 1
+            }
+          },
+          data: [
+            {
+              value: [5000, 7000, 12000, 11000, 15000, 14000],
+              name: 'Allocated Budget'
+            },
+            {
+              value: [4000, 9000, 15000, 15000, 13000, 11000],
+              name: 'Expected Spending'
+            },
+            {
+              value: [5500, 11000, 12000, 15000, 12000, 12000],
+              name: 'Actual Spending'
+            }
+          ],
+          animationDuration: animationDuration
+        }]
+      })
+    }
+  }
+}
+</script>

+ 81 - 0
templates/src/views/dashboard/editor/components/TodoList/Todo.vue

@@ -0,0 +1,81 @@
+<template>
+  <li :class="{ completed: todo.done, editing: editing }" class="todo">
+    <div class="view">
+      <input
+        :checked="todo.done"
+        class="toggle"
+        type="checkbox"
+        @change="toggleTodo( todo)"
+      >
+      <label @dblclick="editing = true" v-text="todo.text" />
+      <button class="destroy" @click="deleteTodo( todo )" />
+    </div>
+    <input
+      v-show="editing"
+      v-focus="editing"
+      :value="todo.text"
+      class="edit"
+      @keyup.enter="doneEdit"
+      @keyup.esc="cancelEdit"
+      @blur="doneEdit"
+    >
+  </li>
+</template>
+
+<script>
+export default {
+  name: 'Todo',
+  directives: {
+    focus(el, { value }, { context }) {
+      if (value) {
+        context.$nextTick(() => {
+          el.focus()
+        })
+      }
+    }
+  },
+  props: {
+    todo: {
+      type: Object,
+      default: function() {
+        return {}
+      }
+    }
+  },
+  data() {
+    return {
+      editing: false
+    }
+  },
+  methods: {
+    deleteTodo(todo) {
+      this.$emit('deleteTodo', todo)
+    },
+    editTodo({ todo, value }) {
+      this.$emit('editTodo', { todo, value })
+    },
+    toggleTodo(todo) {
+      this.$emit('toggleTodo', todo)
+    },
+    doneEdit(e) {
+      const value = e.target.value.trim()
+      const { todo } = this
+      if (!value) {
+        this.deleteTodo({
+          todo
+        })
+      } else if (this.editing) {
+        this.editTodo({
+          todo,
+          value
+        })
+        this.editing = false
+      }
+    },
+    cancelEdit(e) {
+      e.target.value = this.todo.text
+      this.editing = false
+    }
+  }
+}
+</script>

+ 320 - 0
templates/src/views/dashboard/editor/components/TodoList/index.scss

@@ -0,0 +1,320 @@
+.todoapp {
+  font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif;
+  line-height: 1.4em;
+  color: #4d4d4d;
+  min-width: 230px;
+  max-width: 550px;
+  margin: 0 auto ;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+  font-weight: 300;
+  background: #fff;
+  z-index: 1;
+  position: relative;
+  button {
+    margin: 0;
+    padding: 0;
+    border: 0;
+    background: none;
+    font-size: 100%;
+    vertical-align: baseline;
+    font-family: inherit;
+    font-weight: inherit;
+    color: inherit;
+    -webkit-appearance: none;
+    appearance: none;
+    -webkit-font-smoothing: antialiased;
+    -moz-osx-font-smoothing: grayscale;
+  }
+  :focus {
+    outline: 0;
+  }
+  .hidden {
+    display: none;
+  }
+  .todoapp {
+    background: #fff;
+    margin: 130px 0 40px 0;
+    position: relative;
+    box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 0 25px 50px 0 rgba(0, 0, 0, 0.1);
+  }
+  .todoapp input::-webkit-input-placeholder {
+    font-style: italic;
+    font-weight: 300;
+    color: #e6e6e6;
+  }
+  .todoapp input::-moz-placeholder {
+    font-style: italic;
+    font-weight: 300;
+    color: #e6e6e6;
+  }
+  .todoapp input::input-placeholder {
+    font-style: italic;
+    font-weight: 300;
+    color: #e6e6e6;
+  }
+  .todoapp h1 {
+    position: absolute;
+    top: -155px;
+    width: 100%;
+    font-size: 100px;
+    font-weight: 100;
+    text-align: center;
+    color: rgba(175, 47, 47, 0.15);
+    -webkit-text-rendering: optimizeLegibility;
+    -moz-text-rendering: optimizeLegibility;
+    text-rendering: optimizeLegibility;
+  }
+  .new-todo,
+  .edit {
+    position: relative;
+    margin: 0;
+    width: 100%;
+    font-size: 18px;
+    font-family: inherit;
+    font-weight: inherit;
+    line-height: 1.4em;
+    border: 0;
+    color: inherit;
+    padding: 6px;
+    border: 1px solid #999;
+    box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2);
+    box-sizing: border-box;
+    -webkit-font-smoothing: antialiased;
+    -moz-osx-font-smoothing: grayscale;
+  }
+  .new-todo {
+    padding: 10px 16px 16px 60px;
+    border: none;
+    background: rgba(0, 0, 0, 0.003);
+    box-shadow: inset 0 -2px 1px rgba(0, 0, 0, 0.03);
+  }
+  .main {
+    position: relative;
+    z-index: 2;
+    border-top: 1px solid #e6e6e6;
+  }
+  .toggle-all {
+    text-align: center;
+    border: none;
+    /* Mobile Safari */
+    opacity: 0;
+    position: absolute;
+  }
+  .toggle-all+label {
+    width: 60px;
+    height: 34px;
+    font-size: 0;
+    position: absolute;
+    top: -52px;
+    left: -13px;
+    -webkit-transform: rotate(90deg);
+    transform: rotate(90deg);
+  }
+  .toggle-all+label:before {
+    content: '❯';
+    font-size: 22px;
+    color: #e6e6e6;
+    padding: 10px 27px 10px 27px;
+  }
+  .toggle-all:checked+label:before {
+    color: #737373;
+  }
+  .todo-list {
+    margin: 0;
+    padding: 0;
+    list-style: none;
+  }
+  .todo-list li {
+    position: relative;
+    font-size: 24px;
+    border-bottom: 1px solid #ededed;
+  }
+  .todo-list li:last-child {
+    border-bottom: none;
+  }
+  .todo-list li.editing {
+    border-bottom: none;
+    padding: 0;
+  }
+  .todo-list li.editing .edit {
+    display: block;
+    width: 506px;
+    padding: 12px 16px;
+    margin: 0 0 0 43px;
+  }
+  .todo-list li.editing .view {
+    display: none;
+  }
+  .todo-list li .toggle {
+    text-align: center;
+    width: 40px;
+    /* auto, since non-WebKit browsers doesn't support input styling */
+    height: auto;
+    position: absolute;
+    top: 0;
+    bottom: 0;
+    margin: auto 0;
+    border: none;
+    /* Mobile Safari */
+    -webkit-appearance: none;
+    appearance: none;
+  }
+  .todo-list li .toggle {
+    opacity: 0;
+  }
+  .todo-list li .toggle+label {
+    /*
+    Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433
+    IE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/
+  */
+    background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E');
+    background-repeat: no-repeat;
+    background-position: center left;
+    background-size: 36px;
+  }
+  .todo-list li .toggle:checked+label {
+    background-size: 36px;
+    background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E');
+  }
+  .todo-list li label {
+    word-break: break-all;
+    padding: 15px 15px 15px 50px;
+    display: block;
+    line-height: 1.0;
+        font-size: 14px;
+    transition: color 0.4s;
+  }
+  .todo-list li.completed label {
+    color: #d9d9d9;
+    text-decoration: line-through;
+  }
+  .todo-list li .destroy {
+    display: none;
+    position: absolute;
+    top: 0;
+    right: 10px;
+    bottom: 0;
+    width: 40px;
+    height: 40px;
+    margin: auto 0;
+    font-size: 30px;
+    color: #cc9a9a;
+    transition: color 0.2s ease-out;
+    cursor: pointer;
+  }
+  .todo-list li .destroy:hover {
+    color: #af5b5e;
+  }
+  .todo-list li .destroy:after {
+    content: '×';
+  }
+  .todo-list li:hover .destroy {
+    display: block;
+  }
+  .todo-list li .edit {
+    display: none;
+  }
+  .todo-list li.editing:last-child {
+    margin-bottom: -1px;
+  }
+  .footer {
+    color: #777;
+    position: relative;
+    padding: 10px 15px;
+    height: 40px;
+    text-align: center;
+    border-top: 1px solid #e6e6e6;
+  }
+  .footer:before {
+    content: '';
+    position: absolute;
+    right: 0;
+    bottom: 0;
+    left: 0;
+    height: 40px;
+    overflow: hidden;
+    box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), 0 8px 0 -3px #f6f6f6, 0 9px 1px -3px rgba(0, 0, 0, 0.2), 0 16px 0 -6px #f6f6f6, 0 17px 2px -6px rgba(0, 0, 0, 0.2);
+  }
+  .todo-count {
+    float: left;
+    text-align: left;
+  }
+  .todo-count strong {
+    font-weight: 300;
+  }
+  .filters {
+    margin: 0;
+    padding: 0;
+    position: relative;
+    z-index: 1;
+    list-style: none;
+  }
+  .filters li {
+    display: inline;
+  }
+  .filters li a {
+    color: inherit;
+    font-size: 12px;
+    padding: 3px 7px;
+    text-decoration: none;
+    border: 1px solid transparent;
+    border-radius: 3px;
+  }
+  .filters li a:hover {
+    border-color: rgba(175, 47, 47, 0.1);
+  }
+  .filters li a.selected {
+    border-color: rgba(175, 47, 47, 0.2);
+  }
+  .clear-completed,
+  html .clear-completed:active {
+    float: right;
+    position: relative;
+    line-height: 20px;
+    text-decoration: none;
+    cursor: pointer;
+  }
+  .clear-completed:hover {
+    text-decoration: underline;
+  }
+  .info {
+    margin: 65px auto 0;
+    color: #bfbfbf;
+    font-size: 10px;
+    text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
+    text-align: center;
+  }
+  .info p {
+    line-height: 1;
+  }
+  .info a {
+    color: inherit;
+    text-decoration: none;
+    font-weight: 400;
+  }
+  .info a:hover {
+    text-decoration: underline;
+  }
+  /*
+  Hack to remove background from Mobile Safari.
+  Can't use it globally since it destroys checkboxes in Firefox
+*/
+  @media screen and (-webkit-min-device-pixel-ratio:0) {
+    .toggle-all,
+    .todo-list li .toggle {
+      background: none;
+    }
+    .todo-list li .toggle {
+      height: 40px;
+    }
+  }
+  @media (max-width: 430px) {
+    .footer {
+      height: 50px;
+    }
+    .filters {
+      bottom: 10px;
+    }
+  }
+}

+ 127 - 0
templates/src/views/dashboard/editor/components/TodoList/index.vue

@@ -0,0 +1,127 @@
+<template>
+  <section class="todoapp">
+    <!-- header -->
+    <header class="header">
+      <input class="new-todo" autocomplete="off" placeholder="Todo List" @keyup.enter="addTodo">
+    </header>
+    <!-- main section -->
+    <section v-show="todos.length" class="main">
+      <input id="toggle-all" :checked="allChecked" class="toggle-all" type="checkbox" @change="toggleAll({ done: !allChecked })">
+      <label for="toggle-all" />
+      <ul class="todo-list">
+        <todo
+          v-for="(todo, index) in filteredTodos"
+          :key="index"
+          :todo="todo"
+          @toggleTodo="toggleTodo"
+          @editTodo="editTodo"
+          @deleteTodo="deleteTodo"
+        />
+      </ul>
+    </section>
+    <!-- footer -->
+    <footer v-show="todos.length" class="footer">
+      <span class="todo-count">
+        <strong>{{ remaining }}</strong>
+        {{ remaining | pluralize('item') }} left
+      </span>
+      <ul class="filters">
+        <li v-for="(val, key) in filters" :key="key">
+          <a :class="{ selected: visibility === key }" @click.prevent="visibility = key">{{ key | capitalize }}</a>
+        </li>
+      </ul>
+      <!-- <button class="clear-completed" v-show="todos.length > remaining" @click="clearCompleted">
+        Clear completed
+      </button> -->
+    </footer>
+  </section>
+</template>
+
+<script>
+import Todo from './Todo.vue'
+
+const STORAGE_KEY = 'todos'
+const filters = {
+  all: todos => todos,
+  active: todos => todos.filter(todo => !todo.done),
+  completed: todos => todos.filter(todo => todo.done)
+}
+const defalutList = [
+  { text: 'star this repository', done: false },
+  { text: 'fork this repository', done: false },
+  { text: 'follow author', done: false },
+  { text: 'vue-element-admin', done: true },
+  { text: 'vue', done: true },
+  { text: 'element-ui', done: true },
+  { text: 'axios', done: true },
+  { text: 'webpack', done: true }
+]
+export default {
+  components: { Todo },
+  filters: {
+    pluralize: (n, w) => n === 1 ? w : w + 's',
+    capitalize: s => s.charAt(0).toUpperCase() + s.slice(1)
+  },
+  data() {
+    return {
+      visibility: 'all',
+      filters,
+      // todos: JSON.parse(window.localStorage.getItem(STORAGE_KEY)) || defalutList
+      todos: defalutList
+    }
+  },
+  computed: {
+    allChecked() {
+      return this.todos.every(todo => todo.done)
+    },
+    filteredTodos() {
+      return filters[this.visibility](this.todos)
+    },
+    remaining() {
+      return this.todos.filter(todo => !todo.done).length
+    }
+  },
+  methods: {
+    setLocalStorage() {
+      window.localStorage.setItem(STORAGE_KEY, JSON.stringify(this.todos))
+    },
+    addTodo(e) {
+      const text = e.target.value
+      if (text.trim()) {
+        this.todos.push({
+          text,
+          done: false
+        })
+        this.setLocalStorage()
+      }
+      e.target.value = ''
+    },
+    toggleTodo(val) {
+      val.done = !val.done
+      this.setLocalStorage()
+    },
+    deleteTodo(todo) {
+      this.todos.splice(this.todos.indexOf(todo), 1)
+      this.setLocalStorage()
+    },
+    editTodo({ todo, value }) {
+      todo.text = value
+      this.setLocalStorage()
+    },
+    clearCompleted() {
+      this.todos = this.todos.filter(todo => !todo.done)
+      this.setLocalStorage()
+    },
+    toggleAll({ done }) {
+      this.todos.forEach(todo => {
+        todo.done = done
+        this.setLocalStorage()
+      })
+    }
+  }
+}
+</script>
+
+<style lang="scss">
+  @import './index.scss';
+</style>

+ 55 - 0
templates/src/views/dashboard/editor/components/TransactionTable.vue

@@ -0,0 +1,55 @@
+<template>
+  <el-table :data="list" style="width: 100%;padding-top: 15px;">
+    <el-table-column label="Order_No" min-width="200">
+      <template slot-scope="scope">
+        {{ scope.row.order_no | orderNoFilter }}
+      </template>
+    </el-table-column>
+    <el-table-column label="Price" width="195" align="center">
+      <template slot-scope="scope">
+        ¥{{ scope.row.price | toThousandFilter }}
+      </template>
+    </el-table-column>
+    <el-table-column label="Status" width="100" align="center">
+      <template slot-scope="{row}">
+        <el-tag :type="row.status | statusFilter">
+          {{ row.status }}
+        </el-tag>
+      </template>
+    </el-table-column>
+  </el-table>
+</template>
+
+<script>
+import { transactionList } from '@/api/remote-search'
+
+export default {
+  filters: {
+    statusFilter(status) {
+      const statusMap = {
+        success: 'success',
+        pending: 'danger'
+      }
+      return statusMap[status]
+    },
+    orderNoFilter(str) {
+      return str.substring(0, 30)
+    }
+  },
+  data() {
+    return {
+      list: null
+    }
+  },
+  created() {
+    this.fetchData()
+  },
+  methods: {
+    fetchData() {
+      transactionList().then(response => {
+        this.list = response.data.items.slice(0, 8)
+      })
+    }
+  }
+}
+</script>

+ 55 - 0
templates/src/views/dashboard/editor/components/mixins/resize.js

@@ -0,0 +1,55 @@
+import { debounce } from '@/utils'
+
+export default {
+  data() {
+    return {
+      $_sidebarElm: null,
+      $_resizeHandler: null
+    }
+  },
+  mounted() {
+    this.$_resizeHandler = debounce(() => {
+      if (this.chart) {
+        this.chart.resize()
+      }
+    }, 100)
+    this.$_initResizeEvent()
+    this.$_initSidebarResizeEvent()
+  },
+  beforeDestroy() {
+    this.$_destroyResizeEvent()
+    this.$_destroySidebarResizeEvent()
+  },
+  // to fixed bug when cached by keep-alive
+  // https://github.com/PanJiaChen/vue-element-admin/issues/2116
+  activated() {
+    this.$_initResizeEvent()
+    this.$_initSidebarResizeEvent()
+  },
+  deactivated() {
+    this.$_destroyResizeEvent()
+    this.$_destroySidebarResizeEvent()
+  },
+  methods: {
+    // use $_ for mixins properties
+    // https://vuejs.org/v2/style-guide/index.html#Private-property-names-essential
+    $_initResizeEvent() {
+      window.addEventListener('resize', this.$_resizeHandler)
+    },
+    $_destroyResizeEvent() {
+      window.removeEventListener('resize', this.$_resizeHandler)
+    },
+    $_sidebarResizeHandler(e) {
+      if (e.propertyName === 'width') {
+        this.$_resizeHandler()
+      }
+    },
+    $_initSidebarResizeEvent() {
+      this.$_sidebarElm = document.getElementsByClassName('sidebar-container')[0]
+      this.$_sidebarElm && this.$_sidebarElm.addEventListener('transitionend', this.$_sidebarResizeHandler)
+    },
+    $_destroySidebarResizeEvent() {
+      this.$_sidebarElm && this.$_sidebarElm.removeEventListener('transitionend', this.$_sidebarResizeHandler)
+    }
+  }
+}

+ 119 - 52
templates/src/views/dashboard/editor/index.vue

@@ -1,74 +1,141 @@
 <template>
 <template>
   <div class="dashboard-editor-container">
   <div class="dashboard-editor-container">
-    <div class=" clearfix">
-      <pan-thumb :image="avatar" style="float: left">
-        Your roles:
-        <span v-for="item in roles" :key="item" class="pan-info-roles">{{ item }}</span>
-      </pan-thumb>
-      <github-corner style="position: absolute; top: 0px; border: 0; right: 0;" />
-      <div class="info-container">
-        <span class="display_name">{{ name }}</span>
-        <span style="font-size:20px;padding-top:20px;display:inline-block;">Editor's Dashboard</span>
-      </div>
-    </div>
-    <div>
-      <img :src="emptyGif" class="emptyGif">
-    </div>
+    <panel-group @handleSetLineChartData="handleSetLineChartData" />
+
+    <el-row style="background: #fff; padding: 16px 16px 0; margin-bottom: 32px">
+      <line-chart :chart-data="lineChartData" />
+    </el-row>
+
+    <el-row :gutter="32">
+      <el-col :xs="24" :sm="24" :lg="8">
+        <div class="chart-wrapper">
+          <raddar-chart />
+        </div>
+      </el-col>
+      <el-col :xs="24" :sm="24" :lg="8">
+        <div class="chart-wrapper">
+          <pie-chart />
+        </div>
+      </el-col>
+      <el-col :xs="24" :sm="24" :lg="8">
+        <div class="chart-wrapper">
+          <bar-chart />
+        </div>
+      </el-col>
+    </el-row>
+
+    <el-row :gutter="8">
+      <el-col
+        :xs="{ span: 24 }"
+        :sm="{ span: 24 }"
+        :md="{ span: 24 }"
+        :lg="{ span: 12 }"
+        :xl="{ span: 12 }"
+        style="padding-right: 8px; margin-bottom: 30px"
+      >
+        <transaction-table />
+      </el-col>
+      <el-col
+        :xs="{ span: 24 }"
+        :sm="{ span: 12 }"
+        :md="{ span: 12 }"
+        :lg="{ span: 6 }"
+        :xl="{ span: 6 }"
+        style="margin-bottom: 30px"
+      >
+        <todo-list />
+      </el-col>
+      <el-col
+        :xs="{ span: 24 }"
+        :sm="{ span: 12 }"
+        :md="{ span: 12 }"
+        :lg="{ span: 6 }"
+        :xl="{ span: 6 }"
+        style="margin-bottom: 30px"
+      >
+        <box-card />
+      </el-col>
+    </el-row>
   </div>
   </div>
 </template>
 </template>
 
 
 <script>
 <script>
-import { mapGetters } from 'vuex'
-import PanThumb from '@/components/PanThumb'
-import GithubCorner from '@/components/GithubCorner'
+import PanelGroup from './components/PanelGroup'
+import LineChart from './components/LineChart'
+import RaddarChart from './components/RaddarChart'
+import PieChart from './components/PieChart'
+import BarChart from './components/BarChart'
+import TransactionTable from './components/TransactionTable'
+import TodoList from './components/TodoList'
+import BoxCard from './components/BoxCard'
+
+const lineChartData = {
+  newVisitis: {
+    expectedData: [100, 120, 161, 134, 105, 160, 165],
+    actualData: [120, 82, 91, 154, 162, 140, 145]
+  },
+  messages: {
+    expectedData: [200, 192, 120, 144, 160, 130, 140],
+    actualData: [180, 160, 151, 106, 145, 150, 130]
+  },
+  purchases: {
+    expectedData: [80, 100, 121, 104, 105, 90, 100],
+    actualData: [120, 90, 100, 138, 142, 130, 130]
+  },
+  shoppings: {
+    expectedData: [130, 140, 141, 142, 145, 150, 160],
+    actualData: [120, 82, 91, 154, 162, 140, 130]
+  }
+}
 
 
 export default {
 export default {
   name: 'DashboardEditor',
   name: 'DashboardEditor',
-  components: { PanThumb, GithubCorner },
+  components: {
+    PanelGroup,
+    LineChart,
+    RaddarChart,
+    PieChart,
+    BarChart,
+    TransactionTable,
+    TodoList,
+    BoxCard
+  },
   data() {
   data() {
     return {
     return {
-      emptyGif: 'https://wpimg.wallstcn.com/0e03b7da-db9e-4819-ba10-9016ddfdaed3'
+      lineChartData: lineChartData.newVisitis
     }
     }
   },
   },
-  computed: {
-    ...mapGetters([
-      'name',
-      'avatar',
-      'roles'
-    ])
+  methods: {
+    handleSetLineChartData(type) {
+      this.lineChartData = lineChartData[type]
+    }
   }
   }
 }
 }
 </script>
 </script>
 
 
 <style lang="scss" scoped>
 <style lang="scss" scoped>
-  .emptyGif {
-    display: block;
-    width: 45%;
-    margin: 0 auto;
+.dashboard-editor-container {
+  padding: 32px;
+  background-color: rgb(240, 242, 245);
+  position: relative;
+
+  .github-corner {
+    position: absolute;
+    top: 0px;
+    border: 0;
+    right: 0;
   }
   }
 
 
-  .dashboard-editor-container {
-    background-color: #e3e3e3;
-    min-height: 100vh;
-    padding: 50px 60px 0px;
-    .pan-info-roles {
-      font-size: 12px;
-      font-weight: 700;
-      color: #333;
-      display: block;
-    }
-    .info-container {
-      position: relative;
-      margin-left: 190px;
-      height: 150px;
-      line-height: 200px;
-      .display_name {
-        font-size: 48px;
-        line-height: 48px;
-        color: #212121;
-        position: absolute;
-        top: 25px;
-      }
-    }
+  .chart-wrapper {
+    background: #fff;
+    padding: 16px 16px 0;
+    margin-bottom: 32px;
   }
   }
+}
+
+@media (max-width: 1024px) {
+  .chart-wrapper {
+    padding: 8px;
+  }
+}
 </style>
 </style>

+ 1 - 1
templates/src/views/dashboard/index.vue

@@ -24,7 +24,7 @@ export default {
   },
   },
   created() {
   created() {
     console.log('[views/dashboard/index.vue]当前的用户角色[this.roles]', this.roles)
     console.log('[views/dashboard/index.vue]当前的用户角色[this.roles]', this.roles)
-    if (!this.roles.includes('admin')) {
+    if (!this.roles.includes('developer')) {
       this.currentRole = 'editorDashboard'
       this.currentRole = 'editorDashboard'
     }
     }
   }
   }

+ 4 - 7
templates/src/views/error-page/404.vue

@@ -8,13 +8,10 @@
         <img class="pic-404__child right" src="@/assets/404_images/404_cloud.png" alt="404">
         <img class="pic-404__child right" src="@/assets/404_images/404_cloud.png" alt="404">
       </div>
       </div>
       <div class="bullshit">
       <div class="bullshit">
-        <div class="bullshit__oops">OOPS!</div>
-        <div class="bullshit__info">All rights reserved
-          <a style="color:#20a0ff" href="https://wallstreetcn.com" target="_blank">wallstreetcn</a>
-        </div>
+        <div class="bullshit__oops">坏事发生了!</div>
         <div class="bullshit__headline">{{ message }}</div>
         <div class="bullshit__headline">{{ message }}</div>
-        <div class="bullshit__info">Please check that the URL you entered is correct, or click the button below to return to the homepage.</div>
-        <a href="" class="bullshit__return-home">Back to home</a>
+        <div class="bullshit__info">请检查您输入的网址是否正确,或点击下方按钮返回首页。</div>
+        <a href="" class="bullshit__return-home">返回首页</a>
       </div>
       </div>
     </div>
     </div>
   </div>
   </div>
@@ -26,7 +23,7 @@ export default {
   name: 'Page404',
   name: 'Page404',
   computed: {
   computed: {
     message() {
     message() {
-      return 'The webmaster said that you can not enter this page...'
+      return '当前界面,还在建设中...'
     }
     }
   }
   }
 }
 }

+ 380 - 0
templates/src/views/groupMeeting/index.vue

@@ -0,0 +1,380 @@
+<template>
+  <div class="meeting-container">
+    <!-- 轮值信息栏 -->
+    <el-card class="duty-panel">
+      <div slot="header" class="duty-header">
+        <span>轮值信息</span>
+        <el-tag v-if="currentDuty" :type="dutyStatusTagType">
+          {{ currentDuty.status_display }}
+        </el-tag>
+      </div>
+
+      <div v-if="currentDuty" class="duty-content">
+        <div class="duty-info">
+          <div class="info-item">
+            <span class="label">轮值同学:</span>
+            <span class="value">{{ currentDuty.user.name }}</span>
+          </div>
+          <div class="info-item">
+            <span class="label">轮值时间:</span>
+            <span
+              class="value"
+            >{{ currentDuty.start_date }} 至 {{ currentDuty.end_date }}</span>
+          </div>
+          <div class="info-item">
+            <span class="label">本周代办:</span>
+            <span class="value">{{
+              currentDuty.todo_items || "暂无待办事项"
+            }}</span>
+          </div>
+          <div class="info-item">
+            <span class="label">注意事项:</span>
+            <span class="value">{{ currentDuty.notes || "暂无注意事项" }}</span>
+          </div>
+          <div class="info-item">
+            <span class="label">卫生安排:</span>
+            <span class="value">{{
+              currentDuty.cleaning_schedule || "暂无卫生安排"
+            }}</span>
+          </div>
+        </div>
+
+        <el-button
+          v-if="isCurrentDutyUser"
+          type="primary"
+          size="small"
+          @click="editDuty"
+        >
+          编辑轮值信息
+        </el-button>
+      </div>
+
+      <div v-else class="no-duty">当前无轮值信息</div>
+    </el-card>
+
+    <!-- 组会管理 -->
+    <el-card class="meetings-panel">
+      <div slot="header" class="meetings-header">
+        <span>组会管理</span>
+        <el-button
+          v-if="isCurrentDutyUser"
+          type="primary"
+          icon="el-icon-plus"
+          size="small"
+          @click="openCreateDialog"
+        >
+          新增组会
+        </el-button>
+      </div>
+
+      <el-table :data="meetings" style="width: 100%">
+        <el-table-column prop="title" label="组会名称" min-width="150" />
+        <el-table-column
+          prop="meeting_type_display"
+          label="会议形式"
+          width="90"
+        />
+        <el-table-column label="会议地点" min-width="150">
+          <template slot-scope="{ row }">
+            {{ row.location || row.online_link }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="meeting_time" label="会议时间" min-width="150">
+          <template slot-scope="{ row }">
+            {{ formatDateTime(row.meeting_time) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="会议纪要" width="100">
+          <template slot-scope="{ row }">
+            <el-button
+              v-if="isCurrentDutyUser"
+              type="text"
+              size="small"
+              @click="openMeetingEditor(row)"
+            >
+              编辑纪要
+            </el-button>
+          </template>
+        </el-table-column>
+        <el-table-column label="发布状态" width="90">
+          <template slot-scope="{ row }">
+            <el-tag :type="row.published ? 'success' : 'info'" size="small">
+              {{ row.published ? "已发布" : "未发布" }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="120">
+          <template slot-scope="{ row }">
+            <el-button
+              v-if="isCurrentDutyUser && !row.published"
+              type="primary"
+              size="small"
+              @click="publishMeeting(row)"
+            >
+              发布
+            </el-button>
+            <el-button
+              v-else-if="isCurrentDutyUser"
+              type="text"
+              size="small"
+              disabled
+            >
+              已发布
+            </el-button>
+          </template>
+        </el-table-column>
+      </el-table>
+    </el-card>
+
+    <!-- 会议纪要编辑弹窗 -->
+    <el-dialog
+      :title="meetingEditorTitle"
+      :visible.sync="editorVisible"
+      width="80%"
+      top="5vh"
+    >
+      <only-office-editor
+        v-if="editorVisible"
+        :document-id="selectedMeeting.summary_doc_id"
+        :initial-content="selectedMeeting.summary || ''"
+        @save="handleSaveMeetingSummary"
+      />
+      <div slot="footer" class="dialog-footer">
+        <el-button @click="editorVisible = false">取消</el-button>
+        <el-button type="primary" @click="saveMeetingSummary">保存</el-button>
+      </div>
+    </el-dialog>
+
+    <!-- 轮值信息编辑弹窗 -->
+    <el-dialog
+      :title="dutyEditTitle"
+      :visible.sync="dutyEditVisible"
+      width="600px"
+    >
+      <duty-edit-form
+        v-if="dutyEditVisible"
+        :duty="currentDuty"
+        @success="handleDutyUpdated"
+        @cancel="dutyEditVisible = false"
+      />
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { mapGetters } from 'vuex'
+import OnlyOfficeEditor from '@/components/OnlyOfficeEditor'
+import DutyEditForm from '@/components/DutyEditForm'
+import { getauth } from '@/boot/axios_request'
+export default {
+  components: {
+    OnlyOfficeEditor,
+    DutyEditForm
+  },
+
+  data() {
+    return {
+      currentDuty: null,
+      meetings: [],
+      editorVisible: false,
+      selectedMeeting: {},
+      dutyEditVisible: false
+    }
+  },
+
+  computed: {
+    ...mapGetters(['token']),
+
+    meetingEditorTitle() {
+      return this.selectedMeeting.title
+        ? `${this.selectedMeeting.title} - 会议纪要`
+        : '会议纪要'
+    },
+
+    dutyEditTitle() {
+      return this.currentDuty ? '编辑轮值信息' : '添加轮值信息'
+    },
+
+    dutyStatusTagType() {
+      const status = this.currentDuty?.status
+      return status === 'ACT'
+        ? 'success'
+        : status === 'UP'
+          ? 'warning'
+          : 'info'
+    },
+
+    isCurrentDutyUser() {
+      // console.log('isCurrentDutyUser', this.currentDuty.user_token, this.token)
+      // if ((this.token === 'adminzl')) {
+      //   return true
+      // }
+
+      return this.currentDuty?.user_token === this.token
+    }
+  },
+
+  async mounted() {
+    await this.fetchCurrentDuty()
+    await this.fetchMeetings()
+  },
+
+  methods: {
+    // 获取轮值信息
+    async fetchCurrentDuty() {
+      try {
+        getauth('groupMeeting/current/').then((response) => {
+          console.log('fetchCurrentDuty', response)
+          this.handleCurrentDuty(response)
+        })
+      } catch (error) {
+        this.$message.error('获取轮值信息失败')
+        console.error(error)
+      }
+    },
+
+    handleCurrentDuty(data) {
+      console.log('handleCurrentDuty', data)
+      if (!data) {
+        this.currentDuty = null
+        return
+      }
+      try {
+        data.start_date = data.start_date.replace('T', '')
+        data.end_date = data.end_date.replace('T', '')
+
+        this.currentDuty = data
+      } catch (error) {
+        this.$message.error('获取轮值信息失败')
+        console.error(error)
+      }
+    },
+
+    async fetchMeetings() {
+      try {
+        getauth('groupMeeting/meeting/').then((response) => {
+          this.handleMeetings(response.data)
+        })
+      } catch (error) {
+        this.$message.error('获取组会信息失败')
+        console.error(error)
+      }
+    },
+
+    handleMeetings(data) {
+      if (!data) {
+        this.meetings = []
+        return
+      }
+      try {
+        data.results.forEach((meeting) => {
+          meeting.meeting_time = this.formatDateTime(meeting.meeting_time)
+          meeting.published = meeting.published === 'True'
+        })
+        this.meetings = data.results
+      } catch (error) {
+        this.$message.error('获取组会信息失败')
+        console.error(error)
+      }
+    },
+
+    formatDate(dateStr) {
+      return dateStr ? this.$dayjs(dateStr).format('YYYY-MM-DD') : ''
+    },
+
+    formatDateTime(datetimeStr) {
+      return datetimeStr
+        ? this.$dayjs(datetimeStr).format('YYYY-MM-DD HH:mm')
+        : ''
+    },
+
+    openMeetingEditor(meeting) {
+      this.selectedMeeting = meeting
+      this.editorVisible = true
+    },
+
+    async saveMeetingSummary() {
+      this.editorVisible = false
+      try {
+        await this.$api.meeting.updateMeetingSummary(
+          this.selectedMeeting.meeting_id,
+          this.documentContent
+        )
+        this.$message.success('会议纪要已保存')
+      } catch (error) {
+        this.$message.error('保存失败')
+        console.error(error)
+      }
+    },
+
+    async publishMeeting(meeting) {
+      try {
+        await this.$api.meeting.publishMeeting(meeting.meeting_id)
+        this.$message.success('会议已发布')
+        this.fetchMeetings()
+      } catch (error) {
+        this.$message.error('发布失败')
+        console.error(error)
+      }
+    },
+
+    editDuty() {
+      this.dutyEditVisible = true
+    },
+
+    handleDutyUpdated() {
+      this.dutyEditVisible = false
+      this.fetchCurrentDuty()
+    },
+
+    openCreateDialog() {
+      // 实现创建新组会的逻辑
+    }
+  }
+}
+</script>
+
+<style lang="scss">
+.meeting-container {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  padding: 20px;
+
+  .duty-panel,
+  .meetings-panel {
+    margin-bottom: 20px;
+  }
+
+  .duty-header,
+  .meetings-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+  }
+
+  .duty-content {
+    .info-item {
+      margin-bottom: 12px;
+      display: flex;
+
+      .label {
+        font-weight: bold;
+        min-width: 80px;
+        color: #606266;
+      }
+
+      .value {
+        flex: 1;
+        word-break: break-word;
+      }
+    }
+  }
+
+  .no-duty {
+    text-align: center;
+    color: #999;
+    padding: 20px 0;
+  }
+}
+</style>

+ 2 - 3
templates/src/views/guide/index.vue

@@ -1,12 +1,11 @@
 <template>
 <template>
   <div class="app-container">
   <div class="app-container">
     <aside>
     <aside>
-      The guide page is useful for some people who entered the project for the first time. You can briefly introduce the
-      features of the project. Demo is based on
+      引导页对于首次进入项目的人来说非常有用。您可以简要介绍项目的功能特点。 Demo is based on
       <a href="https://github.com/kamranahmedse/driver.js" target="_blank">driver.js.</a>
       <a href="https://github.com/kamranahmedse/driver.js" target="_blank">driver.js.</a>
     </aside>
     </aside>
     <el-button icon="el-icon-question" type="primary" @click.prevent.stop="guide">
     <el-button icon="el-icon-question" type="primary" @click.prevent.stop="guide">
-      Show Guide
+      介绍
     </el-button>
     </el-button>
   </div>
   </div>
 </template>
 </template>

+ 551 - 0
templates/src/views/invoiceRecord/index.vue

@@ -0,0 +1,551 @@
+<template>
+  <div class="invoice-manager">
+    <!-- 头部功能区 -->
+    <el-card class="header-card" shadow="never">
+      <invoice-header />
+    </el-card>
+
+    <!-- 数据表格 -->
+    <el-card class="data-card" shadow="never">
+      <el-button
+        type="primary"
+        icon="el-icon-plus"
+        class="new-btn"
+        @click="dialog = true"
+      >
+        新增发票
+      </el-button>
+      <el-table
+        v-loading="loading"
+        :data="records"
+        stripe
+        style="width: 100%"
+        class="invoice-table"
+      >
+        <el-table-column prop="id" label="ID" width="80" sortable fixed />
+        <el-table-column prop="date" label="日期" width="120" sortable>
+          <template #default="{ row }">
+            {{ formatDate(row.date) }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="amount" label="金额" width="120" sortable>
+          <template #default="{ row }">
+            ¥ {{ row.amount.toFixed(2) }}
+          </template>
+        </el-table-column>
+        <el-table-column prop="purpose" label="用途" width="180" sortable />
+        <el-table-column
+          prop="teacher_name"
+          label="归属老师"
+          width="150"
+          sortable
+        />
+        <el-table-column prop="project" label="归属项目" width="180" sortable />
+        <el-table-column
+          prop="actual_paid"
+          label="实付金额"
+          width="120"
+          sortable
+        >
+          <template #default="{ row }">
+            ¥ {{ row.actual_paid.toFixed(2) }}
+          </template>
+        </el-table-column>
+        <el-table-column label="是否申报" width="120" sortable>
+          <template #default="{ row }">
+            <el-tag :type="row.is_reported ? 'success' : 'info'">
+              {{ row.is_reported ? "已申报" : "未申报" }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="status" label="审批状态" width="120" sortable>
+          <template #default="{ row }">
+            <status-tag :status="row.status" />
+          </template>
+        </el-table-column>
+        <el-table-column label="发票附件" width="140">
+          <template #default="{ row }">
+            <el-link
+              :href="row.invoice_attachment"
+              type="primary"
+              icon="el-icon-document"
+              :underline="false"
+            >
+              下载
+            </el-link>
+          </template>
+        </el-table-column>
+        <el-table-column label="支付附件" width="140">
+          <template #default="{ row }">
+            <el-link
+              :href="row.payment_attachment"
+              type="primary"
+              icon="el-icon-document"
+              :underline="false"
+            >
+              下载
+            </el-link>
+          </template>
+        </el-table-column>
+        <el-table-column label="操作" width="120" fixed="right">
+          <template #default="{ row }">
+            <el-button
+              type="text"
+              icon="el-icon-edit"
+              @click="editRecord(row)"
+            />
+            <el-button
+              type="text"
+              icon="el-icon-delete"
+              style="color: #f56c6c"
+              @click="deleteRecord(row)"
+            />
+          </template>
+        </el-table-column>
+      </el-table>
+
+      <div class="pagination-wrapper">
+        <el-pagination
+          :current-page="page"
+          :page-size="pageSize"
+          :total="total"
+          :page-sizes="[5, 10, 20, 50]"
+          layout="total, sizes, prev, pager, next, jumper"
+          @size-change="handleSizeChange"
+          @current-change="handleCurrentChange"
+        />
+      </div>
+    </el-card>
+
+    <!-- 新增/编辑对话框 -->
+    <el-dialog
+      :title="editMode ? '编辑发票记录' : '新增发票记录'"
+      :visible.sync="dialog"
+      width="650px"
+      center
+      custom-class="invoice-dialog"
+    >
+      <el-form ref="formRef" :model="form" label-width="100px" :rules="rules">
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="金额" prop="amount">
+              <el-input-number
+                v-model.number="form.amount"
+                :precision="2"
+                :min="0"
+                controls-position="right"
+                style="width: 100%"
+              />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="日期" prop="date">
+              <el-date-picker
+                v-model="form.date"
+                type="date"
+                placeholder="选择日期"
+                value-format="yyyy-MM-dd"
+                style="width: 100%"
+              />
+            </el-form-item>
+          </el-col>
+        </el-row>
+
+        <el-form-item label="用途" prop="purpose">
+          <div class="purpose-container">
+            <el-select
+              v-model="form.purpose"
+              placeholder="请选择用途"
+              style="flex: 1"
+            >
+              <el-option
+                v-for="opt in purposes"
+                :key="opt"
+                :label="opt"
+                :value="opt"
+              />
+              <el-option label="其他" value="other" />
+            </el-select>
+            <el-input
+              v-if="form.purpose === 'other'"
+              v-model="form.purpose_other"
+              placeholder="请输入用途"
+              style="margin-left: 10px; flex: 1"
+            />
+          </div>
+        </el-form-item>
+
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="归属老师" prop="teacher">
+              <el-select
+                v-model="form.teacher"
+                placeholder="请选择老师"
+                style="width: 100%"
+              >
+                <el-option
+                  v-for="t in teachers"
+                  :key="t.id"
+                  :label="t.name"
+                  :value="t.id"
+                />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="归属项目" prop="project">
+              <el-input v-model="form.project" placeholder="请输入项目" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="实付金额" prop="actual_paid">
+              <el-input-number
+                v-model.number="form.actual_paid"
+                :precision="2"
+                :min="0"
+                controls-position="right"
+                style="width: 100%"
+              />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="是否申报" prop="is_reported">
+              <el-switch
+                v-model="form.is_reported"
+                active-text="已申报"
+                inactive-text="未申报"
+              />
+            </el-form-item>
+          </el-col>
+        </el-row>
+
+        <el-row :gutter="20">
+          <el-col :span="12">
+            <el-form-item label="发票附件">
+              <file-uploader
+                v-model="form.invoice_attachment"
+                label="上传发票附件"
+              />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="支付附件">
+              <file-uploader
+                v-model="form.payment_attachment"
+                label="上传支付凭证"
+              />
+            </el-form-item>
+          </el-col>
+        </el-row>
+      </el-form>
+
+      <div slot="footer" class="dialog-footer">
+        <el-button @click="dialog = false">取消</el-button>
+        <el-button type="primary" :loading="submitting" @click="submit">
+          {{ editMode ? "保存更改" : "创建记录" }}
+        </el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+// import { getauth } from '@/boot/axios_request'
+import InvoiceHeader from './invoiceHeader'
+
+export default {
+  components: {
+    InvoiceHeader,
+    StatusTag: {
+      functional: true,
+      props: {
+        status: String
+      },
+      render(h, { props }) {
+        const statusInfo = {
+          pending: { text: '待审批', color: 'warning' },
+          approved: { text: '已通过', color: 'success' },
+          rejected: { text: '已拒绝', color: 'danger' }
+        }
+
+        const info = statusInfo[props.status] || {
+          text: props.status,
+          color: 'info'
+        }
+        return h(
+          'el-tag',
+          { class: 'status-tag', type: info.color },
+          info.text
+        )
+      }
+    },
+    FileUploader: {
+      props: ['value', 'label'],
+      render(h) {
+        return h('div', [
+          this.value &&
+            h('div', { class: 'uploaded-file' }, [
+              h('el-link', { props: { type: 'primary', underline: false }}, [
+                h('i', { class: 'el-icon-document' }),
+                this.value.name || '查看文件'
+              ])
+            ]),
+          h(
+            'el-upload',
+            {
+              class: 'file-uploader',
+              props: {
+                action: '/api/upload',
+                showFileList: false,
+                autoUpload: true,
+                onSuccess: this.onSuccess
+              }
+            },
+            [
+              h('el-button', { class: 'upload-btn' }, [
+                h('i', { class: 'el-icon-upload' }),
+                this.label
+              ])
+            ]
+          )
+        ])
+      },
+      methods: {
+        onSuccess(res, file) {
+          this.$emit('input', file)
+        }
+      }
+    }
+  },
+  data() {
+    return {
+      records: [],
+      total: 0,
+      page: 1,
+      pageSize: 10,
+      loading: false,
+      dialog: false,
+      editMode: false,
+      submitting: false,
+      form: {
+        id: null,
+        amount: 0,
+        date: '',
+        purpose: '',
+        purpose_other: '',
+        teacher: null,
+        project: '',
+        actual_paid: 0,
+        is_reported: false,
+        invoice_attachment: null,
+        payment_attachment: null
+      },
+      rules: {
+        amount: [
+          { required: true, message: '请输入金额', trigger: 'blur' },
+          {
+            type: 'number',
+            min: 0.01,
+            message: '金额必须大于0',
+            trigger: 'blur'
+          }
+        ],
+        date: [{ required: true, message: '请选择日期', trigger: 'change' }],
+        purpose: [{ required: true, message: '请选择用途', trigger: 'change' }],
+        teacher: [{ required: true, message: '请选择老师', trigger: 'change' }]
+      },
+      teachers: [],
+      purposes: ['办公', '差旅', '招待', '培训', '设备采购', '营销推广']
+    }
+  },
+  mounted() {
+    this.load()
+    this.fetchTeachers()
+  },
+  methods: {
+    fetchTeachers() {
+      // 模拟老师数据
+      this.teachers = [
+        { id: 1, name: '张老师' },
+        { id: 2, name: '李老师' },
+        { id: 3, name: '王教授' },
+        { id: 4, name: '刘主任' }
+      ]
+    },
+    load() {
+      this.loading = true
+      // 模拟加载数据
+      setTimeout(() => {
+        this.records = Array.from({ length: 15 }, (_, i) => ({
+          id: i + 1,
+          amount: Math.random() * 1000 + 500,
+          date: new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000),
+          purpose:
+            this.purposes[Math.floor(Math.random() * this.purposes.length)],
+          teacher_name:
+            this.teachers[Math.floor(Math.random() * this.teachers.length)]
+              .name,
+          project: ['研发项目', '教育培训', '设备采购', '营销推广'][
+            Math.floor(Math.random() * 4)
+          ],
+          actual_paid: Math.random() * 1000 + 500,
+          is_reported: Math.random() > 0.3,
+          status: ['pending', 'approved', 'rejected'][
+            Math.floor(Math.random() * 3)
+          ],
+          invoice_attachment: { name: `发票${i + 1}.pdf` },
+          payment_attachment: { name: `支付凭证${i + 1}.png` }
+        }))
+        this.total = 15
+        this.loading = false
+      }, 800)
+    },
+    formatDate(date) {
+      return new Date(date).toLocaleDateString()
+    },
+    handleSizeChange(size) {
+      this.pageSize = size
+      this.load()
+    },
+    handleCurrentChange(page) {
+      this.page = page
+      this.load()
+    },
+    editRecord(record) {
+      this.editMode = true
+      this.form = { ...record }
+      this.dialog = true
+    },
+    deleteRecord(record) {
+      this.$confirm(`确定删除发票记录 ${record.id} 吗?`, '确认删除', {
+        confirmButtonText: '删除',
+        cancelButtonText: '取消',
+        type: 'warning'
+      })
+        .then(() => {
+          this.$message.success('记录已删除')
+          // 实际删除操作
+        })
+        .catch(() => {})
+    },
+    submit() {
+      this.$refs.formRef.validate((valid) => {
+        if (valid) {
+          this.submitting = true
+          // 模拟提交
+          setTimeout(() => {
+            this.$message.success(
+              this.editMode ? '记录更新成功' : '记录添加成功'
+            )
+            this.dialog = false
+            this.submitting = false
+            this.load()
+          }, 800)
+        }
+      })
+    }
+  }
+}
+</script>
+
+<style lang="scss">
+.invoice-manager {
+  margin: 0 auto;
+  padding: 20px;
+
+  .header-card {
+    margin-bottom: 20px;
+    border-radius: 8px;
+
+    .header-content {
+      display: flex;
+      justify-content: space-between;
+      .title {
+        margin: 0;
+        font-size: 18px;
+        color: #303133;
+      }
+    }
+  }
+
+  .data-card {
+    border-radius: 8px;
+
+    .invoice-table {
+      margin-bottom: 20px;
+
+      .status-tag {
+        font-weight: 500;
+        padding: 0 6px;
+        border-radius: 3px;
+        min-width: 60px;
+        text-align: center;
+      }
+    }
+
+    .pagination-wrapper {
+      display: flex;
+      justify-content: flex-end;
+      padding: 10px 0;
+    }
+  }
+
+  .invoice-dialog {
+    border-radius: 8px;
+
+    .el-dialog__header {
+      background-color: #f5f7fa;
+      border-bottom: 1px solid #ebeef5;
+      padding: 15px 20px;
+      margin: 0;
+
+      .el-dialog__title {
+        font-size: 16px;
+        font-weight: 600;
+        color: #303133;
+      }
+    }
+
+    .el-form-item__label {
+      font-weight: 500;
+      color: #606266;
+    }
+
+    .purpose-container {
+      display: flex;
+      align-items: center;
+    }
+
+    .file-uploader {
+      margin-top: 5px;
+
+      .upload-btn {
+        width: 100%;
+      }
+
+      .uploaded-file {
+        margin-bottom: 8px;
+        i {
+          margin-right: 5px;
+        }
+      }
+    }
+
+    .dialog-footer {
+      padding-top: 10px;
+      border-top: 1px solid #ebeef5;
+      text-align: right;
+    }
+  }
+
+  .el-link.el-link--primary {
+    font-weight: normal;
+    i {
+      margin-right: 5px;
+    }
+  }
+}
+</style>

+ 308 - 0
templates/src/views/invoiceRecord/invoiceHeader/index.vue

@@ -0,0 +1,308 @@
+<template>
+  <div class="invoice-container">
+    <el-card class="header-card" shadow="hover">
+      <div class="header-actions">
+        <el-button
+          type="primary"
+          icon="el-icon-plus"
+          class="action-btn"
+          @click="openDialog"
+        >
+          新增抬头
+        </el-button>
+      </div>
+
+      <el-collapse v-model="activeNames" accordion class="invoice-collapse">
+        <el-collapse-item
+          v-for="h in headers"
+          :key="h.id"
+          :name="h.id"
+          class="collapse-item"
+        >
+          <template slot="title">
+            <div class="header-title">
+              <i class="el-icon-office-building" />
+              <span class="title-text">{{ h.name }}</span>
+            </div>
+            <el-button
+              type="text"
+              icon="el-icon-document-copy"
+              class="copy-btn"
+              @click.stop="copyAll(h)"
+            >
+              复制
+            </el-button>
+          </template>
+
+          <div class="header-details">
+            <div class="detail-row">
+              <span class="detail-label">纳税人识别号:</span>
+              <span class="detail-value">{{ h.tax_id }}</span>
+            </div>
+            <div class="detail-row">
+              <span class="detail-label">地址:</span>
+              <span class="detail-value">{{ h.address }}</span>
+            </div>
+            <div class="detail-row">
+              <span class="detail-label">开户银行:</span>
+              <span class="detail-value">{{ h.bank }}</span>
+            </div>
+            <div class="detail-row">
+              <span class="detail-label">银行账号:</span>
+              <span class="detail-value">{{ h.account }}</span>
+            </div>
+          </div>
+        </el-collapse-item>
+      </el-collapse>
+    </el-card>
+
+    <!-- 新增对话框 -->
+    <el-dialog
+      :title="currentHeader.id ? '编辑抬头' : '新增抬头'"
+      :visible.sync="dialogVisible"
+      width="600px"
+      center
+    >
+      <el-form :model="form" label-width="100px">
+        <el-form-item label="单位名称">
+          <el-input
+            v-model="form.name"
+            placeholder="请输入单位全称"
+            clearable
+          />
+        </el-form-item>
+        <el-form-item label="纳税人识别号">
+          <el-input
+            v-model="form.tax_id"
+            placeholder="请输入纳税人识别号"
+            clearable
+          />
+        </el-form-item>
+        <el-form-item label="地址">
+          <el-input
+            v-model="form.address"
+            placeholder="请输入注册地址"
+            clearable
+          />
+        </el-form-item>
+        <el-form-item label="开户银行">
+          <el-input
+            v-model="form.bank"
+            placeholder="请输入开户银行全称"
+            clearable
+          />
+        </el-form-item>
+        <el-form-item label="银行账号">
+          <el-input
+            v-model="form.account"
+            placeholder="请输入银行账号"
+            clearable
+          />
+        </el-form-item>
+      </el-form>
+
+      <div slot="footer" class="dialog-footer">
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" @click="submit">提交</el-button>
+      </div>
+    </el-dialog>
+  </div>
+</template>
+
+<script>
+import { getauth, postauth } from '@/boot/axios_request'
+
+export default {
+  name: 'InvoiceHeaderManager',
+  data() {
+    return {
+      headers: [],
+      activeNames: [],
+      dialogVisible: false,
+      form: {
+        id: null,
+        name: '',
+        tax_id: '',
+        address: '',
+        bank: '',
+        account: ''
+      },
+      currentHeader: {}
+    }
+  },
+  mounted() {
+    this.load()
+  },
+  methods: {
+    load() {
+      getauth('invoice/header/')
+        .then((res) => {
+          this.headers = res.results
+          // 自动聚焦
+          // if (this.headers.length > 0) {
+          //   this.activeNames = [this.headers[0].id];
+          // }
+        })
+        .catch((err) => {
+          console.error('加载发票抬头失败', err)
+          this.$message.error('加载失败,请重试')
+        })
+    },
+
+    openDialog(header = null) {
+      if (header) {
+        this.currentHeader = { ...header }
+        this.form = { ...header }
+      } else {
+        this.currentHeader = {}
+        this.form = {
+          id: null,
+          name: '',
+          tax_id: '',
+          address: '',
+          bank: '',
+          account: ''
+        }
+      }
+      this.dialogVisible = true
+    },
+
+    submit() {
+      // const method = this.currentHeader.id ? 'putauth' : 'postauth'
+      const url = this.currentHeader.id
+        ? `invoice/header/${this.currentHeader.id}/`
+        : 'invoice/header/'
+
+      postauth(url, this.form)
+        .then(() => {
+          this.$message.success(
+            this.currentHeader.id ? '更新成功' : '添加成功'
+          )
+          this.dialogVisible = false
+          this.load()
+        })
+        .catch((err) => {
+          console.error('提交失败', err)
+          this.$message.error('操作失败,请重试')
+        })
+    },
+
+    copyAll(header) {
+      const text = `单位名称:${header.name}\n纳税人识别号:${header.tax_id}\n地址:${header.address}\n开户银行:${header.bank}\n银行账号:${header.account}`
+
+      if (navigator.clipboard) {
+        navigator.clipboard
+          .writeText(text)
+          .then(() => this.$message.success('复制成功'))
+          .catch((err) => {
+            console.error('复制失败', err)
+            this.$message.error('复制失败,请手动复制')
+          })
+      } else {
+        const textarea = document.createElement('textarea')
+        textarea.value = text
+        document.body.appendChild(textarea)
+        textarea.select()
+        document.execCommand('copy')
+        document.body.removeChild(textarea)
+        this.$message.success('复制成功')
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.invoice-container {
+  margin: 20px auto;
+}
+
+.header-card {
+  border-radius: 8px;
+  overflow: hidden;
+}
+
+.header-actions {
+  margin-bottom: 20px;
+  display: flex;
+  justify-content: flex-end;
+}
+
+.action-btn {
+  border-radius: 4px;
+  padding: 10px 20px;
+  font-weight: 500;
+}
+
+.invoice-collapse {
+  border: none;
+  border-radius: 6px;
+}
+
+.collapse-item {
+  margin-bottom: 12px;
+  border-radius: 6px;
+  overflow: hidden;
+  box-shadow: 0 1px 6px rgba(0, 0, 0, 0.1);
+  transition: all 0.3s ease;
+}
+
+.collapse-item:hover {
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+  transform: translateY(-2px);
+}
+
+.header-title {
+  display: flex;
+  align-items: center;
+  flex: 1;
+}
+
+.title-text {
+  margin-left: 12px;
+  font-size: 16px;
+  font-weight: 600;
+  color: #2c3e50;
+}
+
+.copy-btn {
+  color: #409eff;
+  font-weight: 500;
+  padding: 0 10px;
+}
+
+.copy-btn:hover {
+  color: #66b1ff;
+}
+
+.header-details {
+  padding: 15px;
+  background-color: #f8fafc;
+  border-radius: 0 0 6px 6px;
+}
+
+.detail-row {
+  padding: 10px 0;
+  display: flex;
+  border-bottom: 1px solid #eee;
+}
+
+.detail-row:last-child {
+  border-bottom: none;
+}
+
+.detail-label {
+  min-width: 120px;
+  color: #5a5e66;
+  font-weight: 500;
+}
+
+.detail-value {
+  color: #2c3e50;
+  flex: 1;
+}
+
+.dialog-footer {
+  text-align: center;
+}
+</style>

+ 18 - 4
templates/src/views/profile/components/Account.vue

@@ -179,7 +179,6 @@
 
 
       <el-form-item>
       <el-form-item>
         <el-button type="primary" @click="submit">更新全部信息</el-button>
         <el-button type="primary" @click="submit">更新全部信息</el-button>
-        <el-button @click="cancel">取消</el-button>
       </el-form-item>
       </el-form-item>
     </el-form>
     </el-form>
 
 
@@ -222,8 +221,7 @@
 <script>
 <script>
 import ImageCropper from '@/components/ImageCropper'
 import ImageCropper from '@/components/ImageCropper'
 import { formatDate } from '@/components/date'
 import { formatDate } from '@/components/date'
-
-import { postauth } from '@/boot/axios_request'
+import { postauth, getauth } from '@/boot/axios_request'
 
 
 export default {
 export default {
   components: { ImageCropper },
   components: { ImageCropper },
@@ -258,6 +256,7 @@ export default {
   data() {
   data() {
     return {
     return {
       profilepath: 'user/profile/',
       profilepath: 'user/profile/',
+      academicProfilepath: 'user/academicProfile/',
       imagecropperShow: false,
       imagecropperShow: false,
       imagecropperKey: 0,
       imagecropperKey: 0,
       image: this.$store.getters.avatar,
       image: this.$store.getters.avatar,
@@ -269,7 +268,8 @@ export default {
         { value: 'PhD', label: '博士生' },
         { value: 'PhD', label: '博士生' },
         { value: 'PD', label: '博士后' },
         { value: 'PD', label: '博士后' },
         { value: 'FAC', label: '教职工' },
         { value: 'FAC', label: '教职工' },
-        { value: 'RES', label: '研究员' }
+        { value: 'RES', label: '研究员' },
+        { value: 'NOR', label: '其他' }
       ],
       ],
       memberRoles: {
       memberRoles: {
         LEAD: { label: '组长', type: 'danger' },
         LEAD: { label: '组长', type: 'danger' },
@@ -288,9 +288,13 @@ export default {
       }
       }
     }
     }
   },
   },
+  created() {
+    this.getAcademicProfile()
+  },
   methods: {
   methods: {
     formatDate,
     formatDate,
     submit() {
     submit() {
+      postauth(this.academicProfilepath, this.user.academic_profile)
       postauth(this.profilepath, this.user).then((res) => {
       postauth(this.profilepath, this.user).then((res) => {
         this.handleSuccess()
         this.handleSuccess()
       })
       })
@@ -360,6 +364,16 @@ export default {
         membership.left_at = null
         membership.left_at = null
         this.$message.success('已重新加入小组')
         this.$message.success('已重新加入小组')
       })
       })
+    },
+    getAcademicProfile() {
+      getauth(this.academicProfilepath).then((res) => {
+        console.log('getAcademicProfile', res.results)
+
+        const { role, enrollment_year, graduation_year, department, major, research_tags, skill_tags } = res.results[0]
+        this.user.academic_profile = { role, enrollment_year, graduation_year, department, major, research_tags, skill_tags }
+
+        console.log(this.user.academic_profile)
+      })
     }
     }
   }
   }
 }
 }

+ 3 - 3
templates/src/views/profile/index.vue

@@ -57,9 +57,9 @@ export default {
         address: this.address,
         address: this.address,
         avatar: this.avatar,
         avatar: this.avatar,
         academic_profile: {
         academic_profile: {
-          role: '',
-          enrollment_year: '',
-          graduation_year: '',
+          role: 'NOR',
+          enrollment_year: '1234',
+          graduation_year: '2345',
           department: '电子科技大学-机械与电气工程学院-机器人感知与信息融合小组',
           department: '电子科技大学-机械与电气工程学院-机器人感知与信息融合小组',
           major: '',
           major: '',
           research_tags: '',
           research_tags: '',

+ 43 - 0
userprofile/migrations/0002_alter_academicprofile_department_and_more.py

@@ -0,0 +1,43 @@
+# Generated by Django 4.1.2 on 2025-08-07 22:55
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('userprofile', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='academicprofile',
+            name='department',
+            field=models.CharField(default='UESTC', max_length=100, verbose_name='院系/研究所'),
+        ),
+        migrations.AlterField(
+            model_name='academicprofile',
+            name='enrollment_year',
+            field=models.PositiveIntegerField(blank=True, help_text='格式:YYYY(如2023)', null=True, verbose_name='入学年份'),
+        ),
+        migrations.AlterField(
+            model_name='academicprofile',
+            name='major',
+            field=models.CharField(default='机械', max_length=100, verbose_name='专业方向'),
+        ),
+        migrations.AlterField(
+            model_name='academicprofile',
+            name='research_tags',
+            field=models.CharField(blank=True, default='', help_text='用逗号分隔多个方向(如:人工智能,教育技术)', max_length=255, null=True, verbose_name='研究方向'),
+        ),
+        migrations.AlterField(
+            model_name='academicprofile',
+            name='role',
+            field=models.CharField(choices=[('UG', '本科生'), ('MS', '硕士生'), ('PhD', '博士生'), ('PD', '博士后'), ('FAC', '教职工'), ('RES', '研究员'), ('NOR', '未指定')], default='NOR', max_length=10, verbose_name='学术身份'),
+        ),
+        migrations.AlterField(
+            model_name='academicprofile',
+            name='skill_tags',
+            field=models.CharField(blank=True, default='', help_text='用逗号分隔多个技能(如:Python,数据分析)', max_length=255, null=True, verbose_name='技能标签'),
+        ),
+    ]

+ 13 - 3
userprofile/models.py

@@ -66,6 +66,9 @@ class Users(models.Model):
             self.user_name.save()
             self.user_name.save()
         super().save(*args, **kwargs)
         super().save(*args, **kwargs)
 
 
+    def is_admin(self):
+        return self.roles == self.UserRole.ADMIN
+
 
 
 class AcademicProfile(models.Model):
 class AcademicProfile(models.Model):
     """学术信息扩展模型 (与用户一对一关联)"""
     """学术信息扩展模型 (与用户一对一关联)"""
@@ -76,6 +79,7 @@ class AcademicProfile(models.Model):
         POSTDOC = 'PD', _('博士后')
         POSTDOC = 'PD', _('博士后')
         FACULTY = 'FAC', _('教职工')
         FACULTY = 'FAC', _('教职工')
         RESEARCHER = 'RES', _('研究员')
         RESEARCHER = 'RES', _('研究员')
+        NOROLE = 'NOR', _('未指定')
     
     
     user = models.OneToOneField(
     user = models.OneToOneField(
         Users,
         Users,
@@ -88,11 +92,13 @@ class AcademicProfile(models.Model):
     role = models.CharField(
     role = models.CharField(
         max_length=10,
         max_length=10,
         choices=AcademicRole.choices,
         choices=AcademicRole.choices,
-        default=AcademicRole.MASTER,
+        default=AcademicRole.NOROLE,
         verbose_name='学术身份'
         verbose_name='学术身份'
     )
     )
     # 年级信息
     # 年级信息
     enrollment_year = models.PositiveIntegerField(
     enrollment_year = models.PositiveIntegerField(
+        null=True,
+        blank=True,
         verbose_name='入学年份',
         verbose_name='入学年份',
         help_text='格式:YYYY(如2023)'
         help_text='格式:YYYY(如2023)'
     )
     )
@@ -102,18 +108,22 @@ class AcademicProfile(models.Model):
         verbose_name='预计毕业年份'
         verbose_name='预计毕业年份'
     )
     )
     # 学术单位
     # 学术单位
-    department = models.CharField(max_length=100, verbose_name='院系/研究所')
-    major = models.CharField(max_length=100, verbose_name='专业方向')
+    department = models.CharField(max_length=100, verbose_name='院系/研究所',default='UESTC')
+    major = models.CharField(max_length=100, verbose_name='专业方向',default='机械')
     
     
     # 研究标签
     # 研究标签
     research_tags = models.CharField(
     research_tags = models.CharField(
         max_length=255,
         max_length=255,
+        default='',
+        null=True,
         blank=True,
         blank=True,
         verbose_name='研究方向',
         verbose_name='研究方向',
         help_text='用逗号分隔多个方向(如:人工智能,教育技术)'
         help_text='用逗号分隔多个方向(如:人工智能,教育技术)'
     )
     )
     skill_tags = models.CharField(
     skill_tags = models.CharField(
         max_length=255,
         max_length=255,
+        default='',
+        null=True,
         blank=True,
         blank=True,
         verbose_name='技能标签',
         verbose_name='技能标签',
         help_text='用逗号分隔多个技能(如:Python,数据分析)'
         help_text='用逗号分隔多个技能(如:Python,数据分析)'

+ 16 - 3
userprofile/serializers.py

@@ -12,9 +12,16 @@ class UsersGetSerializer(serializers.ModelSerializer):
         fields = ['user_id','name', 'roles','avatar_url', 'introduction','create_time', 'update_time','email', 'phone', 'address']
         fields = ['user_id','name', 'roles','avatar_url', 'introduction','create_time', 'update_time','email', 'phone', 'address']
 
 
     def get_roles(self, obj):
     def get_roles(self, obj):
+        roles = []
+        if obj.roles == 'ADM':
+            roles.append('admin')
+        else:
+            roles.append('editor')
 
 
-        # return obj.roles.all()
-        return ['admin']
+        if  obj.developer:
+            roles.append('developer')
+        return roles
+        # return ['admin']
 
 
     def get_introduction(self, obj):
     def get_introduction(self, obj):
         return 'I am a super administrator'
         return 'I am a super administrator'
@@ -37,4 +44,10 @@ class AcademicProfileGetSerializer(serializers.ModelSerializer):
     user = UsersGetSerializer(read_only=True)
     user = UsersGetSerializer(read_only=True)
     class Meta:
     class Meta:
         model = AcademicProfile
         model = AcademicProfile
-        fields = ['user', 'degree', 'department', 'university', 'advisor', 'thesis_title', 'thesis_abstract', 'thesis_keywords']
+        fields = [ 'user','role', 'enrollment_year', 'graduation_year', 'department','major','research_tags','skill_tags']
+
+class AcademicProfilePostSerializer(serializers.ModelSerializer):
+
+    class Meta:
+        model = AcademicProfile
+        fields = [ 'role', 'enrollment_year', 'graduation_year', 'department','major','research_tags','skill_tags']

+ 3 - 1
userprofile/urls.py

@@ -6,7 +6,9 @@ urlpatterns=[
 path('profile/', views.UserprofileViewSet.as_view({'get': 'list','post': 'update'}), name='groups'),
 path('profile/', views.UserprofileViewSet.as_view({'get': 'list','post': 'update'}), name='groups'),
 re_path(r'^profile/(?P<pk>[0-9]+)/$', views.UserprofileViewSet.as_view({'get': 'retrieve', 'post': 'update', 'patch': 'partial_update', 'delete': 'destroy'}), name='groups'),
 re_path(r'^profile/(?P<pk>[0-9]+)/$', views.UserprofileViewSet.as_view({'get': 'retrieve', 'post': 'update', 'patch': 'partial_update', 'delete': 'destroy'}), name='groups'),
 
 
-path('avatar-upload/', views.AvatarUploadView.as_view(), name='groups')
+path('avatar-upload/', views.AvatarUploadView.as_view(), name='groups'),
 
 
+path('academicProfile/', views.AcademicProfileViewSet.as_view({'get': 'list','post': 'update'}), name='groups'),
+re_path(r'^profile/(?P<pk>[0-9]+)/$', views.AcademicProfileViewSet.as_view({'get': 'retrieve', 'post': 'update', 'patch': 'partial_update', 'delete': 'destroy'}), name='groups')
 
 
 ]
 ]

+ 43 - 43
userprofile/views.py

@@ -13,7 +13,7 @@ from django.conf import settings
 from .models import Users, AcademicProfile, ResearchGroup, GroupMembership
 from .models import Users, AcademicProfile, ResearchGroup, GroupMembership
 from .filter import UsersFilter, AcademicProfileFilter, ResearchGroupFilter, GroupMembershipFilter
 from .filter import UsersFilter, AcademicProfileFilter, ResearchGroupFilter, GroupMembershipFilter
 from .serializers import UsersGetSerializer,UsersPostSerializer
 from .serializers import UsersGetSerializer,UsersPostSerializer
-from .serializers import AcademicProfileGetSerializer
+from .serializers import AcademicProfileGetSerializer, AcademicProfilePostSerializer
 
 
 class UserprofileViewSet(viewsets.ModelViewSet):
 class UserprofileViewSet(viewsets.ModelViewSet):
     """
     """
@@ -84,30 +84,19 @@ class UserprofileViewSet(viewsets.ModelViewSet):
     # 表格的写法
     # 表格的写法
     def update(self, request, *args, **kwargs):
     def update(self, request, *args, **kwargs):
         if 'pk' in self.kwargs:
         if 'pk' in self.kwargs:
-
             qs = self.get_object()
             qs = self.get_object()
             serializer = self.get_serializer(qs, data=request.data, partial=True)
             serializer = self.get_serializer(qs, data=request.data, partial=True)
             serializer.is_valid(raise_exception=True)
             serializer.is_valid(raise_exception=True)
             serializer.save()
             serializer.save()
             return Response(serializer.data, status=status.HTTP_200_OK, headers=self.get_success_headers(serializer.data))
             return Response(serializer.data, status=status.HTTP_200_OK, headers=self.get_success_headers(serializer.data))
         else:
         else:
-            # 没有qs,应该是管理员更新自己的信息
+            # 没有qs,应该是管理员和用户更新自己的信息
             qs = request.auth
             qs = request.auth
             serializer = self.get_serializer(qs, data=request.data, partial=True)
             serializer = self.get_serializer(qs, data=request.data, partial=True)
             serializer.is_valid(raise_exception=True)
             serializer.is_valid(raise_exception=True)
             serializer.save()
             serializer.save()
             return Response(serializer.data, status=status.HTTP_200_OK, headers=self.get_success_headers(serializer.data))
             return Response(serializer.data, status=status.HTTP_200_OK, headers=self.get_success_headers(serializer.data))
         
         
- 
-
-    
-    # 
-    # def update(self, request, *args, **kwargs):
-    #     qs = request.auth
-    #     serializer = self.get_serializer(qs, data=request.data, partial=True)
-    #     serializer.is_valid(raise_exception=True)
-    #     serializer.save()
-    #     return Response(serializer.data, status=status.HTTP_200_OK, headers=self.get_success_headers(serializer.data))
 
 
     def partial_update(self, request, *args, **kwargs):
     def partial_update(self, request, *args, **kwargs):
         qs = self.get_object()
         qs = self.get_object()
@@ -190,11 +179,23 @@ class AcademicProfileViewSet(viewsets.ModelViewSet):
 
 
     def get_project(self):
     def get_project(self):
         try:
         try:
-            id = self.kwargs.get('pk')
-            return id
+            # 表格通道
+            if 'pk' in self.kwargs:
+                id = self.kwargs.get('pk')
+                return id
+            else:
+            # 用户通道
+                user = self.request.auth
+                if user.roles == 'USR':
+                    # id= AcademicProfile.objects.filter(user=user).first().id
+                    return user.user_id
+
+                else:
+                    return None
         except:
         except:
             return None
             return None
 
 
+
     def get_queryset(self):
     def get_queryset(self):
         project_id = self.get_project()
         project_id = self.get_project()
         if project_id:
         if project_id:
@@ -204,38 +205,37 @@ class AcademicProfileViewSet(viewsets.ModelViewSet):
 
 
     def get_serializer_class(self):
     def get_serializer_class(self):
         if self.action in ['list','retrieve']:
         if self.action in ['list','retrieve']:
-            return UsersGetSerializer
+            return AcademicProfileGetSerializer 
         elif self.action in ['create', 'update', 'partial_update']:
         elif self.action in ['create', 'update', 'partial_update']:
-            return UsersPostSerializer
+            return AcademicProfilePostSerializer 
         else:
         else:
-            return UsersGetSerializer
+            return AcademicProfileGetSerializer 
         
         
-    def create(self, request, *args, **kwargs):
-        serializer = self.get_serializer(data=request.data)
-        serializer.is_valid(raise_exception=True)
-        serializer.save()
-
-        headers = self.get_success_headers(serializer.data)
-        return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
     
     
-    # 表格的写法
-    # def update(self, request, *args, **kwargs):
-    #     qs = self.get_object()
-    #     serializer = self.get_serializer(qs, data=request.data, partial=True)
-    #     serializer.is_valid(raise_exception=True)
-    #     self.perform_update(serializer)
-    #     return Response(serializer.data)   
-    # 
     def update(self, request, *args, **kwargs):
     def update(self, request, *args, **kwargs):
-        qs = request.auth
-        serializer = self.get_serializer(qs, data=request.data, partial=True)
-        serializer.is_valid(raise_exception=True)
-        serializer.save()
-        return Response(serializer.data, status=status.HTTP_200_OK, headers=self.get_success_headers(serializer.data))
+        if 'pk' in self.kwargs:
+            qs = self.get_object()
+            serializer = self.get_serializer(qs, data=request.data, partial=True)
+            serializer.is_valid(raise_exception=True)
+            serializer.save()
+            return Response(serializer.data, status=status.HTTP_200_OK, headers=self.get_success_headers(serializer.data))
+        else:
+            # 没有qs,应该是管理员和用户更新自己的信息
+            qs = request.auth
+            AcademicProfile_obj = AcademicProfile.objects.filter(user=qs).first()
+            if not AcademicProfile_obj:
+                AcademicProfile_obj = AcademicProfile.objects.create(user=qs)
+            serializer = self.get_serializer(AcademicProfile_obj, data=request.data, partial=True)
+            serializer.is_valid(raise_exception=True)
+            serializer.save()
+            return Response(serializer.data, status=status.HTTP_200_OK, headers=self.get_success_headers(serializer.data))
+    
+    def create(self, request, *args, **kwargs):
+        return super().create(request, *args, **kwargs)
 
 
     def partial_update(self, request, *args, **kwargs):
     def partial_update(self, request, *args, **kwargs):
-        qs = self.get_object()
-        serializer = self.get_serializer(qs, data=request.data, partial=True)
-        serializer.is_valid(raise_exception=True)
-        serializer.save()
-
+        return super().partial_update(request, *args, **kwargs)
+    
+    def destroy(self, request, *args, **kwargs):
+        return super().destroy(request, *args, **kwargs)
+    

+ 2 - 1
userregister/views.py

@@ -66,6 +66,7 @@ def register(request, *args, **kwargs):
                                 userzl = User.objects.create_user(username='adminzl',password=str(123456))
                                 userzl = User.objects.create_user(username='adminzl',password=str(123456))
                                 Users.objects.create(
                                 Users.objects.create(
                                                 user_name = userzl,
                                                 user_name = userzl,
+                                                roles = Users.UserRole.ADMIN,
                                                 user_id=userzl.id, name='adminzl',
                                                 user_id=userzl.id, name='adminzl',
                                                 openid="adminzl", appid="adminzl",
                                                 openid="adminzl", appid="adminzl",
                                                 t_code=Md5.md5(str(timezone.now())),
                                                 t_code=Md5.md5(str(timezone.now())),
@@ -79,7 +80,7 @@ def register(request, *args, **kwargs):
                                                 user_id=user.id, name=str(data['name']),
                                                 user_id=user.id, name=str(data['name']),
                                                 openid=transaction_code, appid=Md5.md5(data['name'] + '1'),
                                                 openid=transaction_code, appid=Md5.md5(data['name'] + '1'),
                                                 t_code=Md5.md5(str(timezone.now())),
                                                 t_code=Md5.md5(str(timezone.now())),
-                                                developer=1, ip=ip)
+                                                developer=0, ip=ip)
                             auth.login(request, user)
                             auth.login(request, user)