Browse Source

组会通知

flower_bs 4 weeks ago
parent
commit
522e4089de
70 changed files with 4254 additions and 822 deletions
  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.messages',
     'django.contrib.staticfiles',
+    'invoice.apps.InvoiceConfig',
+    'groupMeeting.apps.GroupmeetingConfig',
 
     'userprofile.apps.UserprofileConfig',
     'userregister.apps.UserregisterConfig',

+ 2 - 0
greaterwms/urls.py

@@ -17,6 +17,8 @@ urlpatterns = [
     path('login/', include('userlogin.urls')),
     path('register/', include('userregister.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('^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 user = require('./user')
-const role = require('./role')
+// const role = require('./role')
 const article = require('./article')
 const search = require('./remote-search')
 
 const mocks = [
   ...user,
-  ...role,
+  // ...role,
   ...article,
   ...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",
     "jsonlint": "1.6.3",
     "jszip": "3.2.1",
+    "lottie-web": "^5.7.11",
     "normalize.css": "7.0.0",
     "nprogress": "0.2.0",
+    "onlyoffice-vue": "^1.0.1",
     "path-to-regexp": "2.4.0",
     "screenfull": "4.2.0",
     "script-loader": "0.7.2",
@@ -41,7 +43,6 @@
     "vue-splitpane": "1.0.4",
     "vuedraggable": "2.20.0",
     "vuex": "3.1.0",
-    "lottie-web": "^5.7.11",
     "xlsx": "0.14.1"
   },
   "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>
   <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" />
 
     <div class="right-menu">
-      <template v-if="device!=='mobile'">
+      <template v-if="device !== 'mobile'">
         <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" />
 
-        <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>
 
-      <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">
-          <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" />
         </div>
         <el-dropdown-menu slot="dropdown">
@@ -32,7 +43,7 @@
             <el-dropdown-item>首页</el-dropdown-item>
           </router-link>
           <el-dropdown-item divided @click.native="logout">
-            <span style="display:block;">退出登录</span>
+            <span style="display: block">退出登录</span>
           </el-dropdown-item>
         </el-dropdown-menu>
       </el-dropdown>
@@ -44,26 +55,20 @@
 import { mapGetters } from 'vuex'
 import Breadcrumb from '@/components/Breadcrumb'
 import Hamburger from '@/components/Hamburger'
-import ErrorLog from '@/components/ErrorLog'
+import Guide from '@/components/Guide'
 import Screenfull from '@/components/Screenfull'
-import SizeSelect from '@/components/SizeSelect'
 import Search from '@/components/HeaderSearch'
 
 export default {
   components: {
     Breadcrumb,
     Hamburger,
-    ErrorLog,
+    Guide,
     Screenfull,
-    SizeSelect,
     Search
   },
   computed: {
-    ...mapGetters([
-      'sidebar',
-      'avatar',
-      'device'
-    ])
+    ...mapGetters(['sidebar', 'avatar', 'device'])
   },
   methods: {
     toggleSideBar() {
@@ -83,18 +88,18 @@ export default {
   overflow: hidden;
   position: relative;
   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 {
     line-height: 46px;
     height: 100%;
     float: left;
     cursor: pointer;
-    transition: background .3s;
-    -webkit-tap-highlight-color:transparent;
+    transition: background 0.3s;
+    -webkit-tap-highlight-color: transparent;
 
     &:hover {
-      background: rgba(0, 0, 0, .025)
+      background: rgba(0, 0, 0, 0.025);
     }
   }
 
@@ -126,10 +131,10 @@ export default {
 
       &.hover-effect {
         cursor: pointer;
-        transition: background .3s;
+        transition: background 0.3s;
 
         &: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 class="drawer-item">
-        <span>固定头部</span>
+        <span>固定主页</span>
         <el-switch v-model="fixedHeader" class="drawer-switch" />
       </div>
 

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

@@ -4,6 +4,7 @@
     <sidebar class="sidebar-container" />
     <div :class="{hasTagsView:needTagsView}" class="main-container">
       <div :class="{'fixed-header':fixedHeader}">
+        <!-- 顶部功能栏 -->
         <navbar />
         <tags-view v-if="needTagsView" />
       </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',
     component: Layout,
     redirect: '/guide/index',
+    hidden: true,
     children: [
       {
         path: 'index',
         component: () => import('@/views/guide/index'),
         name: 'Guide',
-        meta: { title: '引导', icon: 'guide', noCache: true }
+        meta: {
+          title: '引导',
+          icon: 'guide',
+          noCache: true
+        }
       }
     ]
   },
@@ -96,7 +136,7 @@ export const asyncRoutes = [
     meta: {
       title: '权限管理',
       icon: 'lock',
-      roles: ['admin', 'editor']
+      roles: ['admin', 'developer']
     },
     children: [
       {
@@ -105,7 +145,7 @@ export const asyncRoutes = [
         name: 'PagePermission',
         meta: {
           title: '页面权限',
-          roles: ['admin']
+          roles: ['admin', 'developer']
         }
       },
       {
@@ -113,7 +153,8 @@ export const asyncRoutes = [
         component: () => import('@/views/permission/directive'),
         name: 'DirectivePermission',
         meta: {
-          title: '指令权限'
+          title: '指令权限',
+          roles: ['admin', 'developer']
         }
       },
       {
@@ -135,7 +176,13 @@ export const asyncRoutes = [
         path: 'index',
         component: () => import('@/views/icons/index'),
         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',
     meta: {
       title: '示例中心',
+      roles: ['developer'],
       icon: 'el-icon-s-help'
     },
     children: [
@@ -182,7 +230,7 @@ export const asyncRoutes = [
         path: 'index',
         component: () => import('@/views/tab/index'),
         name: 'Tab',
-        meta: { title: '标签页管理', icon: 'tab' }
+        meta: { title: '标签页管理', roles: ['developer'], icon: 'tab' }
       }
     ]
   },
@@ -193,6 +241,7 @@ export const asyncRoutes = [
     name: 'ErrorPages',
     meta: {
       title: '错误页面',
+      roles: ['developer'],
       icon: '404'
     },
     children: [
@@ -218,7 +267,7 @@ export const asyncRoutes = [
         path: 'log',
         component: () => import('@/views/error-log/index'),
         name: 'ErrorLog',
-        meta: { title: '错误日志', icon: 'bug' }
+        meta: { title: '错误日志', roles: ['developer'], icon: 'bug' }
       }
     ]
   },
@@ -229,6 +278,7 @@ export const asyncRoutes = [
     name: 'Excel',
     meta: {
       title: 'Excel操作',
+      roles: ['developer'],
       icon: 'excel'
     },
     children: [
@@ -264,7 +314,7 @@ export const asyncRoutes = [
     redirect: '/zip/download',
     alwaysShow: true,
     name: 'Zip',
-    meta: { title: '压缩管理', icon: 'zip' },
+    meta: { title: '压缩管理', roles: ['developer'], icon: 'zip' },
     children: [
       {
         path: 'download',
@@ -283,7 +333,7 @@ export const asyncRoutes = [
         path: 'index',
         component: () => import('@/views/pdf/index'),
         name: 'PDF',
-        meta: { title: 'PDF操作', icon: 'pdf' }
+        meta: { title: 'PDF操作', roles: ['developer'], icon: 'pdf' }
       }
     ]
   },
@@ -300,7 +350,7 @@ export const asyncRoutes = [
         path: 'index',
         component: () => import('@/views/theme/index'),
         name: 'Theme',
-        meta: { title: '主题设置', icon: 'theme' }
+        meta: { title: '主题设置', roles: ['developer'], icon: 'theme' }
       }
     ]
   },
@@ -312,27 +362,18 @@ export const asyncRoutes = [
         path: 'index',
         component: () => import('@/views/clipboard/index'),
         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 }
 ]
 
-const createRouter = () => new Router({
-  scrollBehavior: () => ({ y: 0 }),
-  routes: constantRoutes
-})
+const createRouter = () =>
+  new Router({
+    scrollBehavior: () => ({ y: 0 }),
+    routes: constantRoutes
+  })
 
 const router = createRouter()
 

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

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

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

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

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

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

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

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

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

@@ -1,27 +1,27 @@
 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) {
   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 {
     return true
   }
 }
 
 /**
- * Filter asynchronous routing tables by recursion
- * @param routes asyncRoutes
- * @param roles
+ * 通过递归过滤异步路由表
+ * @param routes 异步路由表
+ * @param roles 用户角色列表
  */
 export function filterAsyncRoutes(routes, roles) {
   const res = []
 
-  routes.forEach(route => {
+  routes.forEach((route) => {
     const tmp = { ...route }
     if (hasPermission(roles, tmp)) {
       if (tmp.children) {
@@ -48,7 +48,7 @@ const mutations = {
 
 const actions = {
   generateRoutes({ commit }, roles) {
-    return new Promise(resolve => {
+    return new Promise((resolve) => {
       let accessedRoutes
       if (roles.includes('admin')) {
         accessedRoutes = asyncRoutes || []

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

@@ -1,10 +1,8 @@
 <template>
   <div class="dashboard-editor-container">
-    <github-corner class="github-corner" />
-
     <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" />
     </el-row>
 
@@ -27,13 +25,34 @@
     </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;">
+      <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;">
+      <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;">
+      <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>
@@ -41,7 +60,6 @@
 </template>
 
 <script>
-import GithubCorner from '@/components/GithubCorner'
 import PanelGroup from './components/PanelGroup'
 import LineChart from './components/LineChart'
 import RaddarChart from './components/RaddarChart'
@@ -73,7 +91,6 @@ const lineChartData = {
 export default {
   name: 'DashboardAdmin',
   components: {
-    GithubCorner,
     PanelGroup,
     LineChart,
     RaddarChart,
@@ -116,7 +133,7 @@ export default {
   }
 }
 
-@media (max-width:1024px) {
+@media (max-width: 1024px) {
   .chart-wrapper {
     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>
   <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>
 </template>
 
 <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 {
   name: 'DashboardEditor',
-  components: { PanThumb, GithubCorner },
+  components: {
+    PanelGroup,
+    LineChart,
+    RaddarChart,
+    PieChart,
+    BarChart,
+    TransactionTable,
+    TodoList,
+    BoxCard
+  },
   data() {
     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>
 
 <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>

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

@@ -24,7 +24,7 @@ export default {
   },
   created() {
     console.log('[views/dashboard/index.vue]当前的用户角色[this.roles]', this.roles)
-    if (!this.roles.includes('admin')) {
+    if (!this.roles.includes('developer')) {
       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">
       </div>
       <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__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>
@@ -26,7 +23,7 @@ export default {
   name: 'Page404',
   computed: {
     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>
   <div class="app-container">
     <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>
     </aside>
     <el-button icon="el-icon-question" type="primary" @click.prevent.stop="guide">
-      Show Guide
+      介绍
     </el-button>
   </div>
 </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-button type="primary" @click="submit">更新全部信息</el-button>
-        <el-button @click="cancel">取消</el-button>
       </el-form-item>
     </el-form>
 
@@ -222,8 +221,7 @@
 <script>
 import ImageCropper from '@/components/ImageCropper'
 import { formatDate } from '@/components/date'
-
-import { postauth } from '@/boot/axios_request'
+import { postauth, getauth } from '@/boot/axios_request'
 
 export default {
   components: { ImageCropper },
@@ -258,6 +256,7 @@ export default {
   data() {
     return {
       profilepath: 'user/profile/',
+      academicProfilepath: 'user/academicProfile/',
       imagecropperShow: false,
       imagecropperKey: 0,
       image: this.$store.getters.avatar,
@@ -269,7 +268,8 @@ export default {
         { value: 'PhD', label: '博士生' },
         { value: 'PD', label: '博士后' },
         { value: 'FAC', label: '教职工' },
-        { value: 'RES', label: '研究员' }
+        { value: 'RES', label: '研究员' },
+        { value: 'NOR', label: '其他' }
       ],
       memberRoles: {
         LEAD: { label: '组长', type: 'danger' },
@@ -288,9 +288,13 @@ export default {
       }
     }
   },
+  created() {
+    this.getAcademicProfile()
+  },
   methods: {
     formatDate,
     submit() {
+      postauth(this.academicProfilepath, this.user.academic_profile)
       postauth(this.profilepath, this.user).then((res) => {
         this.handleSuccess()
       })
@@ -360,6 +364,16 @@ export default {
         membership.left_at = null
         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,
         avatar: this.avatar,
         academic_profile: {
-          role: '',
-          enrollment_year: '',
-          graduation_year: '',
+          role: 'NOR',
+          enrollment_year: '1234',
+          graduation_year: '2345',
           department: '电子科技大学-机械与电气工程学院-机器人感知与信息融合小组',
           major: '',
           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()
         super().save(*args, **kwargs)
 
+    def is_admin(self):
+        return self.roles == self.UserRole.ADMIN
+
 
 class AcademicProfile(models.Model):
     """学术信息扩展模型 (与用户一对一关联)"""
@@ -76,6 +79,7 @@ class AcademicProfile(models.Model):
         POSTDOC = 'PD', _('博士后')
         FACULTY = 'FAC', _('教职工')
         RESEARCHER = 'RES', _('研究员')
+        NOROLE = 'NOR', _('未指定')
     
     user = models.OneToOneField(
         Users,
@@ -88,11 +92,13 @@ class AcademicProfile(models.Model):
     role = models.CharField(
         max_length=10,
         choices=AcademicRole.choices,
-        default=AcademicRole.MASTER,
+        default=AcademicRole.NOROLE,
         verbose_name='学术身份'
     )
     # 年级信息
     enrollment_year = models.PositiveIntegerField(
+        null=True,
+        blank=True,
         verbose_name='入学年份',
         help_text='格式:YYYY(如2023)'
     )
@@ -102,18 +108,22 @@ class AcademicProfile(models.Model):
         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(
         max_length=255,
+        default='',
+        null=True,
         blank=True,
         verbose_name='研究方向',
         help_text='用逗号分隔多个方向(如:人工智能,教育技术)'
     )
     skill_tags = models.CharField(
         max_length=255,
+        default='',
+        null=True,
         blank=True,
         verbose_name='技能标签',
         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']
 
     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):
         return 'I am a super administrator'
@@ -37,4 +44,10 @@ class AcademicProfileGetSerializer(serializers.ModelSerializer):
     user = UsersGetSerializer(read_only=True)
     class Meta:
         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'),
 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 .filter import UsersFilter, AcademicProfileFilter, ResearchGroupFilter, GroupMembershipFilter
 from .serializers import UsersGetSerializer,UsersPostSerializer
-from .serializers import AcademicProfileGetSerializer
+from .serializers import AcademicProfileGetSerializer, AcademicProfilePostSerializer
 
 class UserprofileViewSet(viewsets.ModelViewSet):
     """
@@ -84,30 +84,19 @@ class UserprofileViewSet(viewsets.ModelViewSet):
     # 表格的写法
     def update(self, request, *args, **kwargs):
         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,应该是管理员和用户更新自己的信息
             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 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):
         qs = self.get_object()
@@ -190,11 +179,23 @@ class AcademicProfileViewSet(viewsets.ModelViewSet):
 
     def get_project(self):
         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:
             return None
 
+
     def get_queryset(self):
         project_id = self.get_project()
         if project_id:
@@ -204,38 +205,37 @@ class AcademicProfileViewSet(viewsets.ModelViewSet):
 
     def get_serializer_class(self):
         if self.action in ['list','retrieve']:
-            return UsersGetSerializer
+            return AcademicProfileGetSerializer 
         elif self.action in ['create', 'update', 'partial_update']:
-            return UsersPostSerializer
+            return AcademicProfilePostSerializer 
         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):
-        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):
-        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))
                                 Users.objects.create(
                                                 user_name = userzl,
+                                                roles = Users.UserRole.ADMIN,
                                                 user_id=userzl.id, name='adminzl',
                                                 openid="adminzl", appid="adminzl",
                                                 t_code=Md5.md5(str(timezone.now())),
@@ -79,7 +80,7 @@ def register(request, *args, **kwargs):
                                                 user_id=user.id, name=str(data['name']),
                                                 openid=transaction_code, appid=Md5.md5(data['name'] + '1'),
                                                 t_code=Md5.md5(str(timezone.now())),
-                                                developer=1, ip=ip)
+                                                developer=0, ip=ip)
                             auth.login(request, user)