feat: add stage 0 foundation
This commit is contained in:
16
.env.example
Normal file
16
.env.example
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
DJANGO_SECRET_KEY=dev-only-change-me
|
||||||
|
DJANGO_DEBUG=true
|
||||||
|
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
|
||||||
|
CORS_ALLOWED_ORIGINS=http://localhost:5173
|
||||||
|
|
||||||
|
POSTGRES_DB=ai_ticket_platform
|
||||||
|
POSTGRES_USER=ai_ticket
|
||||||
|
POSTGRES_PASSWORD=ai_ticket
|
||||||
|
POSTGRES_HOST=postgres
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
|
||||||
|
CELERY_BROKER_URL=redis://redis:6379/0
|
||||||
|
CELERY_RESULT_BACKEND=redis://redis:6379/1
|
||||||
|
|
||||||
|
MINIO_ROOT_USER=minio
|
||||||
|
MINIO_ROOT_PASSWORD=minio123
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -2,6 +2,7 @@
|
|||||||
venv/
|
venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
@@ -9,6 +10,9 @@ __pycache__/
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
|
*.tsbuildinfo
|
||||||
|
frontend/vite.config.js
|
||||||
|
frontend/vite.config.d.ts
|
||||||
.next/
|
.next/
|
||||||
.turbo/
|
.turbo/
|
||||||
|
|
||||||
|
|||||||
1
backend/apps/__init__.py
Normal file
1
backend/apps/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
backend/apps/accounts/__init__.py
Normal file
1
backend/apps/accounts/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
3
backend/apps/accounts/api.py
Normal file
3
backend/apps/accounts/api.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from ninja import Router
|
||||||
|
|
||||||
|
router = Router(tags=["accounts"])
|
||||||
6
backend/apps/accounts/apps.py
Normal file
6
backend/apps/accounts/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AccountsConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.accounts"
|
||||||
1
backend/apps/accounts/management/__init__.py
Normal file
1
backend/apps/accounts/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
backend/apps/accounts/management/commands/__init__.py
Normal file
1
backend/apps/accounts/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
from django.contrib.auth.models import Group, Permission
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from apps.accounts.permissions import DEFAULT_GROUPS, PERMISSIONS
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Seed MVP permissions and default user groups."
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
content_type, _ = ContentType.objects.get_or_create(
|
||||||
|
app_label="system",
|
||||||
|
model="platformpermission",
|
||||||
|
)
|
||||||
|
|
||||||
|
permissions_by_codename: dict[str, Permission] = {}
|
||||||
|
for spec in PERMISSIONS:
|
||||||
|
permission, _ = Permission.objects.get_or_create(
|
||||||
|
content_type=content_type,
|
||||||
|
codename=spec.codename,
|
||||||
|
defaults={"name": spec.name},
|
||||||
|
)
|
||||||
|
if permission.name != spec.name:
|
||||||
|
permission.name = spec.name
|
||||||
|
permission.save(update_fields=["name"])
|
||||||
|
permissions_by_codename[spec.codename] = permission
|
||||||
|
|
||||||
|
for group_name, codenames in DEFAULT_GROUPS.items():
|
||||||
|
group, _ = Group.objects.get_or_create(name=group_name)
|
||||||
|
group.permissions.set(permissions_by_codename[codename] for codename in codenames)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS("Seeded permissions and groups."))
|
||||||
1
backend/apps/accounts/models.py
Normal file
1
backend/apps/accounts/models.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Use Django's built-in User, Group, and Permission models for MVP RBAC.
|
||||||
61
backend/apps/accounts/permissions.py
Normal file
61
backend/apps/accounts/permissions.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PermissionSpec:
|
||||||
|
codename: str
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
PERMISSIONS: tuple[PermissionSpec, ...] = (
|
||||||
|
PermissionSpec("ticket.view", "View tickets"),
|
||||||
|
PermissionSpec("ticket.create", "Create tickets"),
|
||||||
|
PermissionSpec("ticket.edit", "Edit tickets"),
|
||||||
|
PermissionSpec("ticket.export", "Export tickets"),
|
||||||
|
PermissionSpec("ticket.assign", "Assign tickets"),
|
||||||
|
PermissionSpec("ticket.close", "Close tickets"),
|
||||||
|
PermissionSpec("knowledge.view", "View knowledge"),
|
||||||
|
PermissionSpec("knowledge.manage", "Manage knowledge"),
|
||||||
|
PermissionSpec("solution.view", "View solutions"),
|
||||||
|
PermissionSpec("solution.manage", "Manage solutions"),
|
||||||
|
PermissionSpec("agent.use", "Use Agents"),
|
||||||
|
PermissionSpec("agent.manage", "Manage Agents"),
|
||||||
|
PermissionSpec("model.manage", "Manage model configs"),
|
||||||
|
PermissionSpec("tool.manage", "Manage MCP tools"),
|
||||||
|
PermissionSpec("audit.view", "View audit logs"),
|
||||||
|
PermissionSpec("system.manage", "Manage system settings"),
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_GROUPS: dict[str, tuple[str, ...]] = {
|
||||||
|
"System Admin": tuple(permission.codename for permission in PERMISSIONS),
|
||||||
|
"Knowledge Admin": (
|
||||||
|
"ticket.view",
|
||||||
|
"knowledge.view",
|
||||||
|
"knowledge.manage",
|
||||||
|
"solution.view",
|
||||||
|
"solution.manage",
|
||||||
|
"agent.use",
|
||||||
|
),
|
||||||
|
"Support Engineer": (
|
||||||
|
"ticket.view",
|
||||||
|
"ticket.create",
|
||||||
|
"ticket.edit",
|
||||||
|
"ticket.assign",
|
||||||
|
"ticket.close",
|
||||||
|
"knowledge.view",
|
||||||
|
"solution.view",
|
||||||
|
"agent.use",
|
||||||
|
),
|
||||||
|
"Requester": (
|
||||||
|
"ticket.view",
|
||||||
|
"ticket.create",
|
||||||
|
"knowledge.view",
|
||||||
|
"solution.view",
|
||||||
|
),
|
||||||
|
"Auditor": (
|
||||||
|
"ticket.view",
|
||||||
|
"knowledge.view",
|
||||||
|
"solution.view",
|
||||||
|
"audit.view",
|
||||||
|
),
|
||||||
|
}
|
||||||
1
backend/apps/accounts/schemas.py
Normal file
1
backend/apps/accounts/schemas.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
backend/apps/accounts/tests/__init__.py
Normal file
1
backend/apps/accounts/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
17
backend/apps/accounts/tests/test_permissions_seed.py
Normal file
17
backend/apps/accounts/tests/test_permissions_seed.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from django.contrib.auth.models import Group, Permission
|
||||||
|
from django.core.management import call_command
|
||||||
|
|
||||||
|
from apps.accounts.permissions import DEFAULT_GROUPS, PERMISSIONS
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_permissions_creates_permissions_and_groups(db):
|
||||||
|
call_command("seed_permissions")
|
||||||
|
|
||||||
|
assert Permission.objects.filter(
|
||||||
|
codename__in=[permission.codename for permission in PERMISSIONS]
|
||||||
|
).count() == len(PERMISSIONS)
|
||||||
|
|
||||||
|
for group_name, codenames in DEFAULT_GROUPS.items():
|
||||||
|
group = Group.objects.get(name=group_name)
|
||||||
|
actual = set(group.permissions.values_list("codename", flat=True))
|
||||||
|
assert actual == set(codenames)
|
||||||
1
backend/apps/audit/__init__.py
Normal file
1
backend/apps/audit/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
26
backend/apps/audit/api.py
Normal file
26
backend/apps/audit/api.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from ninja import Router, Schema
|
||||||
|
|
||||||
|
from apps.audit.models import OperationAuditLog
|
||||||
|
|
||||||
|
router = Router(tags=["audit"])
|
||||||
|
|
||||||
|
|
||||||
|
class OperationAuditLogOut(Schema):
|
||||||
|
id: int
|
||||||
|
module: str
|
||||||
|
action: str
|
||||||
|
target_type: str
|
||||||
|
target_id: str
|
||||||
|
request_snapshot: dict[str, Any]
|
||||||
|
result: str
|
||||||
|
ip_address: str | None
|
||||||
|
user_agent: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/operation-logs", response=list[OperationAuditLogOut])
|
||||||
|
def list_operation_logs(request):
|
||||||
|
return OperationAuditLog.objects.all()[:100]
|
||||||
6
backend/apps/audit/apps.py
Normal file
6
backend/apps/audit/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AuditConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.audit"
|
||||||
61
backend/apps/audit/migrations/0001_initial.py
Normal file
61
backend/apps/audit/migrations/0001_initial.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Generated by Django 5.2.15 on 2026-06-30 07:47
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='OperationAuditLog',
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
'id',
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name='ID',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
('module', models.CharField(max_length=80)),
|
||||||
|
('action', models.CharField(max_length=80)),
|
||||||
|
('target_type', models.CharField(max_length=80)),
|
||||||
|
('target_id', models.CharField(blank=True, max_length=120)),
|
||||||
|
('request_snapshot', models.JSONField(blank=True, default=dict)),
|
||||||
|
('result', models.CharField(max_length=20)),
|
||||||
|
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
|
||||||
|
('user_agent', models.TextField(blank=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
(
|
||||||
|
'user',
|
||||||
|
models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name='operation_audit_logs',
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
'indexes': [
|
||||||
|
models.Index(fields=['module', 'action'], name='audit_opera_module_8f87af_idx'),
|
||||||
|
models.Index(
|
||||||
|
fields=['target_type', 'target_id'],
|
||||||
|
name='audit_opera_target__560d76_idx',
|
||||||
|
),
|
||||||
|
models.Index(fields=['created_at'], name='audit_opera_created_077b40_idx'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
backend/apps/audit/migrations/__init__.py
Normal file
0
backend/apps/audit/migrations/__init__.py
Normal file
32
backend/apps/audit/models.py
Normal file
32
backend/apps/audit/models.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class OperationAuditLog(models.Model):
|
||||||
|
user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="operation_audit_logs",
|
||||||
|
)
|
||||||
|
module = models.CharField(max_length=80)
|
||||||
|
action = models.CharField(max_length=80)
|
||||||
|
target_type = models.CharField(max_length=80)
|
||||||
|
target_id = models.CharField(max_length=120, blank=True)
|
||||||
|
request_snapshot = models.JSONField(default=dict, blank=True)
|
||||||
|
result = models.CharField(max_length=20)
|
||||||
|
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||||
|
user_agent = models.TextField(blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["module", "action"]),
|
||||||
|
models.Index(fields=["target_type", "target_id"]),
|
||||||
|
models.Index(fields=["created_at"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.module}.{self.action}:{self.result}"
|
||||||
1
backend/apps/audit/tests/__init__.py
Normal file
1
backend/apps/audit/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
20
backend/apps/audit/tests/test_audit_api.py
Normal file
20
backend/apps/audit/tests/test_audit_api.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from apps.audit.models import OperationAuditLog
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_operation_logs_returns_recent_logs(client, db):
|
||||||
|
OperationAuditLog.objects.create(
|
||||||
|
module="system",
|
||||||
|
action="health_check",
|
||||||
|
target_type="system",
|
||||||
|
target_id="health",
|
||||||
|
result="success",
|
||||||
|
request_snapshot={"path": "/api/system/health"},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.get("/api/audit/operation-logs")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
payload = response.json()
|
||||||
|
assert len(payload) == 1
|
||||||
|
assert payload[0]["module"] == "system"
|
||||||
|
assert payload[0]["action"] == "health_check"
|
||||||
1
backend/apps/system/__init__.py
Normal file
1
backend/apps/system/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
8
backend/apps/system/api.py
Normal file
8
backend/apps/system/api.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from ninja import Router
|
||||||
|
|
||||||
|
router = Router(tags=["system"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
def health(request):
|
||||||
|
return {"status": "ok"}
|
||||||
6
backend/apps/system/apps.py
Normal file
6
backend/apps/system/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class SystemConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.system"
|
||||||
1
backend/apps/system/tests/__init__.py
Normal file
1
backend/apps/system/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
5
backend/apps/system/tests/test_health_api.py
Normal file
5
backend/apps/system/tests/test_health_api.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
def test_health_endpoint_returns_ok(client):
|
||||||
|
response = client.get("/api/system/health")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"status": "ok"}
|
||||||
1
backend/config/__init__.py
Normal file
1
backend/config/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
7
backend/config/asgi.py
Normal file
7
backend/config/asgi.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local")
|
||||||
|
|
||||||
|
application = get_asgi_application()
|
||||||
9
backend/config/celery.py
Normal file
9
backend/config/celery.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from celery import Celery
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local")
|
||||||
|
|
||||||
|
app = Celery("ai_ticket_platform")
|
||||||
|
app.config_from_object("django.conf:settings", namespace="CELERY")
|
||||||
|
app.autodiscover_tasks()
|
||||||
1
backend/config/settings/__init__.py
Normal file
1
backend/config/settings/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
80
backend/config/settings/base.py
Normal file
80
backend/config/settings/base.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parents[3]
|
||||||
|
|
||||||
|
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "dev-only-change-me")
|
||||||
|
DEBUG = os.getenv("DJANGO_DEBUG", "false").lower() == "true"
|
||||||
|
ALLOWED_HOSTS = os.getenv("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1").split(",")
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
"django.contrib.admin",
|
||||||
|
"django.contrib.auth",
|
||||||
|
"django.contrib.contenttypes",
|
||||||
|
"django.contrib.sessions",
|
||||||
|
"django.contrib.messages",
|
||||||
|
"django.contrib.staticfiles",
|
||||||
|
"corsheaders",
|
||||||
|
"apps.accounts",
|
||||||
|
"apps.audit",
|
||||||
|
"apps.system",
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
"corsheaders.middleware.CorsMiddleware",
|
||||||
|
"django.middleware.security.SecurityMiddleware",
|
||||||
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
|
"django.middleware.common.CommonMiddleware",
|
||||||
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = "config.urls"
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
|
"DIRS": [],
|
||||||
|
"APP_DIRS": True,
|
||||||
|
"OPTIONS": {
|
||||||
|
"context_processors": [
|
||||||
|
"django.template.context_processors.debug",
|
||||||
|
"django.template.context_processors.request",
|
||||||
|
"django.contrib.auth.context_processors.auth",
|
||||||
|
"django.contrib.messages.context_processors.messages",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = "config.wsgi.application"
|
||||||
|
ASGI_APPLICATION = "config.asgi.application"
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
"default": {
|
||||||
|
"ENGINE": "django.db.backends.postgresql",
|
||||||
|
"NAME": os.getenv("POSTGRES_DB", "ai_ticket_platform"),
|
||||||
|
"USER": os.getenv("POSTGRES_USER", "ai_ticket"),
|
||||||
|
"PASSWORD": os.getenv("POSTGRES_PASSWORD", "ai_ticket"),
|
||||||
|
"HOST": os.getenv("POSTGRES_HOST", "localhost"),
|
||||||
|
"PORT": int(os.getenv("POSTGRES_PORT", "5432")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LANGUAGE_CODE = "zh-hans"
|
||||||
|
TIME_ZONE = "Asia/Shanghai"
|
||||||
|
USE_I18N = True
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
STATIC_URL = "static/"
|
||||||
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
|
CORS_ALLOWED_ORIGINS = os.getenv(
|
||||||
|
"CORS_ALLOWED_ORIGINS",
|
||||||
|
"http://localhost:5173",
|
||||||
|
).split(",")
|
||||||
|
|
||||||
|
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
|
||||||
|
CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", "redis://localhost:6379/1")
|
||||||
3
backend/config/settings/local.py
Normal file
3
backend/config/settings/local.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .base import * # noqa: F403
|
||||||
|
|
||||||
|
DEBUG = True
|
||||||
12
backend/config/settings/test.py
Normal file
12
backend/config/settings/test.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from .base import * # noqa: F403
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
"default": {
|
||||||
|
"ENGINE": "django.db.backends.sqlite3",
|
||||||
|
"NAME": ":memory:",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PASSWORD_HASHERS = [
|
||||||
|
"django.contrib.auth.hashers.MD5PasswordHasher",
|
||||||
|
]
|
||||||
15
backend/config/urls.py
Normal file
15
backend/config/urls.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import path
|
||||||
|
from ninja import NinjaAPI
|
||||||
|
|
||||||
|
from apps.audit.api import router as audit_router
|
||||||
|
from apps.system.api import router as system_router
|
||||||
|
|
||||||
|
api = NinjaAPI(title="AI Ticket Platform API", version="0.1.0")
|
||||||
|
api.add_router("/system", system_router)
|
||||||
|
api.add_router("/audit", audit_router)
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("admin/", admin.site.urls),
|
||||||
|
path("api/", api.urls),
|
||||||
|
]
|
||||||
7
backend/config/wsgi.py
Normal file
7
backend/config/wsgi.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local")
|
||||||
|
|
||||||
|
application = get_wsgi_application()
|
||||||
14
backend/manage.py
Normal file
14
backend/manage.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local")
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
52
backend/pyproject.toml
Normal file
52
backend/pyproject.toml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
[project]
|
||||||
|
name = "ai-ticket-platform-backend"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Backend for AI-native technical support ticket platform"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"Django>=5.0,<5.3",
|
||||||
|
"django-ninja>=1.3,<2.0",
|
||||||
|
"psycopg[binary]>=3.2,<4.0",
|
||||||
|
"celery>=5.4,<6.0",
|
||||||
|
"redis>=5.0,<6.0",
|
||||||
|
"django-cors-headers>=4.4,<5.0",
|
||||||
|
"pydantic>=2.8,<3.0",
|
||||||
|
"python-dotenv>=1.0,<2.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=69"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["apps*", "config*"]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.2,<9.0",
|
||||||
|
"pytest-django>=4.8,<5.0",
|
||||||
|
"ruff>=0.6,<1.0",
|
||||||
|
"mypy>=1.11,<2.0",
|
||||||
|
"django-stubs[compatible-mypy]>=5.0,<6.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
DJANGO_SETTINGS_MODULE = "config.settings.test"
|
||||||
|
python_files = ["test_*.py", "*_test.py"]
|
||||||
|
testpaths = ["apps", "tests"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py312"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "UP", "B"]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.12"
|
||||||
|
plugins = ["mypy_django_plugin.main"]
|
||||||
|
ignore_missing_imports = true
|
||||||
|
strict_optional = true
|
||||||
|
|
||||||
|
[tool.django-stubs]
|
||||||
|
django_settings_module = "config.settings.test"
|
||||||
1
backend/tests/__init__.py
Normal file
1
backend/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
1
backend/tests/conftest.py
Normal file
1
backend/tests/conftest.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
41
docs/development/local-setup.md
Normal file
41
docs/development/local-setup.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Local Setup
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Python 3.12
|
||||||
|
- Node.js 22
|
||||||
|
- Docker Desktop
|
||||||
|
|
||||||
|
## First Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
copy .env.example .env
|
||||||
|
cd infra
|
||||||
|
docker compose --env-file ../.env up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend:
|
||||||
|
|
||||||
|
- API: http://localhost:8000/api/docs
|
||||||
|
- Health: http://localhost:8000/api/system/health
|
||||||
|
|
||||||
|
Frontend:
|
||||||
|
|
||||||
|
- App: http://localhost:5173
|
||||||
|
|
||||||
|
## Backend Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
python -m pip install -e ".[dev]"
|
||||||
|
pytest -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend Checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run typecheck
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
36
docs/frontend/design-system.md
Normal file
36
docs/frontend/design-system.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Frontend Design System
|
||||||
|
|
||||||
|
## Principles
|
||||||
|
|
||||||
|
- Build product screens from shared tokens and primitives.
|
||||||
|
- Keep operational pages dense, quiet, and optimized for repeated use.
|
||||||
|
- Use semantic status colors for ticket state, priority, audit, AI, and system feedback.
|
||||||
|
|
||||||
|
## Tokens
|
||||||
|
|
||||||
|
Theme tokens live in `frontend/src/styles/globals.css` and are exposed through Tailwind in
|
||||||
|
`frontend/tailwind.config.ts`.
|
||||||
|
|
||||||
|
Core tokens:
|
||||||
|
|
||||||
|
- `background`, `foreground`, `surface`, `border`
|
||||||
|
- `muted`, `muted-foreground`
|
||||||
|
- `primary`, `success`, `warning`, `danger`, `info`, `ai`
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
Base UI primitives:
|
||||||
|
|
||||||
|
- `Button`
|
||||||
|
- `Badge`
|
||||||
|
- `Card`, `CardHeader`, `CardContent`
|
||||||
|
|
||||||
|
Product components:
|
||||||
|
|
||||||
|
- `AppPageHeader`
|
||||||
|
- `AppStatusBadge`
|
||||||
|
- `AppPriorityBadge`
|
||||||
|
|
||||||
|
Preview page:
|
||||||
|
|
||||||
|
- `frontend/src/features/design-system/DesignSystemPreview.tsx`
|
||||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>AI Ticket Platform</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3784
frontend/package-lock.json
generated
Normal file
3784
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
frontend/package.json
Normal file
34
frontend/package.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "ai-ticket-platform-frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"typecheck": "tsc -b",
|
||||||
|
"lint": "eslint src --ext ts,tsx"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"tailwind-merge": "^2.5.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^26.0.1",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.0",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"eslint": "^9.16.0",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.16",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
frontend/src/app/App.tsx
Normal file
5
frontend/src/app/App.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { DesignSystemPreview } from '@/features/design-system/DesignSystemPreview'
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return <DesignSystemPreview />
|
||||||
|
}
|
||||||
20
frontend/src/app/layout/AppShell.tsx
Normal file
20
frontend/src/app/layout/AppShell.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type * as React from 'react'
|
||||||
|
|
||||||
|
import { Sidebar } from './Sidebar'
|
||||||
|
import { Topbar } from './Topbar'
|
||||||
|
|
||||||
|
type AppShellProps = {
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppShell({ children }: AppShellProps) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background text-foreground">
|
||||||
|
<Sidebar />
|
||||||
|
<div className="min-h-screen lg:pl-64">
|
||||||
|
<Topbar />
|
||||||
|
<main>{children}</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
9
frontend/src/app/layout/Sidebar.tsx
Normal file
9
frontend/src/app/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export function Sidebar() {
|
||||||
|
return (
|
||||||
|
<aside className="fixed inset-y-0 left-0 hidden w-64 border-r border-border bg-surface lg:block">
|
||||||
|
<div className="border-b border-border px-4 py-3">
|
||||||
|
<p className="text-sm font-semibold text-foreground">AI Ticket Platform</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
7
frontend/src/app/layout/Topbar.tsx
Normal file
7
frontend/src/app/layout/Topbar.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export function Topbar() {
|
||||||
|
return (
|
||||||
|
<header className="flex h-14 items-center border-b border-border bg-surface px-6">
|
||||||
|
<p className="text-sm text-muted-foreground">工单工作台</p>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
5
frontend/src/app/router.tsx
Normal file
5
frontend/src/app/router.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { App } from './App'
|
||||||
|
|
||||||
|
export function Router() {
|
||||||
|
return <App />
|
||||||
|
}
|
||||||
19
frontend/src/components/app/AppPageHeader.tsx
Normal file
19
frontend/src/components/app/AppPageHeader.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type * as React from 'react'
|
||||||
|
|
||||||
|
type AppPageHeaderProps = {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
actions?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppPageHeader({ title, description, actions }: AppPageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold tracking-normal text-foreground">{title}</h1>
|
||||||
|
{description ? <p className="mt-1 text-sm text-muted-foreground">{description}</p> : null}
|
||||||
|
</div>
|
||||||
|
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
frontend/src/components/app/AppPriorityBadge.tsx
Normal file
13
frontend/src/components/app/AppPriorityBadge.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
||||||
|
const PRIORITY_LABELS: Record<string, string> = {
|
||||||
|
low: '低',
|
||||||
|
medium: '中',
|
||||||
|
high: '高',
|
||||||
|
urgent: '紧急'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppPriorityBadge({ priority }: { priority: string }) {
|
||||||
|
const variant = priority === 'urgent' ? 'danger' : priority === 'high' ? 'warning' : 'default'
|
||||||
|
return <Badge variant={variant}>{PRIORITY_LABELS[priority] ?? priority}</Badge>
|
||||||
|
}
|
||||||
15
frontend/src/components/app/AppStatusBadge.tsx
Normal file
15
frontend/src/components/app/AppStatusBadge.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
pending_info: '待补充',
|
||||||
|
pending: '待处理',
|
||||||
|
in_progress: '处理中',
|
||||||
|
pending_confirm: '待确认',
|
||||||
|
resolved: '已解决',
|
||||||
|
closed: '已关闭'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppStatusBadge({ status }: { status: string }) {
|
||||||
|
const variant = status === 'resolved' || status === 'closed' ? 'success' : 'info'
|
||||||
|
return <Badge variant={variant}>{STATUS_LABELS[status] ?? status}</Badge>
|
||||||
|
}
|
||||||
29
frontend/src/components/ui/badge.tsx
Normal file
29
frontend/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type * as React from 'react'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'border-border bg-muted text-foreground',
|
||||||
|
success: 'border-transparent bg-success/10 text-success',
|
||||||
|
warning: 'border-transparent bg-warning/15 text-warning',
|
||||||
|
danger: 'border-transparent bg-danger/10 text-danger',
|
||||||
|
info: 'border-transparent bg-info/10 text-info',
|
||||||
|
ai: 'border-transparent bg-ai/10 text-ai'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
type BadgeProps = React.HTMLAttributes<HTMLSpanElement> & VariantProps<typeof badgeVariants>
|
||||||
|
|
||||||
|
export function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return <span className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
}
|
||||||
37
frontend/src/components/ui/button.tsx
Normal file
37
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Slot } from '@radix-ui/react-slot'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex h-9 items-center justify-center rounded-md px-3 text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:opacity-90',
|
||||||
|
secondary: 'border border-border bg-surface text-foreground hover:bg-muted',
|
||||||
|
ghost: 'text-foreground hover:bg-muted',
|
||||||
|
danger: 'bg-danger text-white hover:opacity-90'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button'
|
||||||
|
return <Comp className={cn(buttonVariants({ variant }), className)} ref={ref} {...props} />
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Button.displayName = 'Button'
|
||||||
15
frontend/src/components/ui/card.tsx
Normal file
15
frontend/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
|
||||||
|
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn('rounded-lg border border-border bg-surface', className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn('border-b border-border px-4 py-3', className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn('p-4', className)} {...props} />
|
||||||
|
}
|
||||||
48
frontend/src/features/design-system/DesignSystemPreview.tsx
Normal file
48
frontend/src/features/design-system/DesignSystemPreview.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { AppPageHeader } from '@/components/app/AppPageHeader'
|
||||||
|
import { AppPriorityBadge } from '@/components/app/AppPriorityBadge'
|
||||||
|
import { AppStatusBadge } from '@/components/app/AppStatusBadge'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||||
|
|
||||||
|
export function DesignSystemPreview() {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-background p-6 text-foreground">
|
||||||
|
<div className="mx-auto flex max-w-6xl flex-col gap-6">
|
||||||
|
<AppPageHeader
|
||||||
|
title="设计系统预览"
|
||||||
|
description="业务页面必须基于这些 token 和组件开发。"
|
||||||
|
actions={<Button>创建工单</Button>}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h2 className="text-sm font-medium">Buttons</h2>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-wrap gap-2">
|
||||||
|
<Button>Primary</Button>
|
||||||
|
<Button variant="secondary">Secondary</Button>
|
||||||
|
<Button variant="ghost">Ghost</Button>
|
||||||
|
<Button variant="danger">Danger</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h2 className="text-sm font-medium">Badges</h2>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-wrap gap-2">
|
||||||
|
<Badge>Default</Badge>
|
||||||
|
<Badge variant="success">Success</Badge>
|
||||||
|
<Badge variant="warning">Warning</Badge>
|
||||||
|
<Badge variant="danger">Danger</Badge>
|
||||||
|
<Badge variant="info">Info</Badge>
|
||||||
|
<Badge variant="ai">AI</Badge>
|
||||||
|
<AppStatusBadge status="in_progress" />
|
||||||
|
<AppPriorityBadge priority="urgent" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
6
frontend/src/lib/cn.ts
Normal file
6
frontend/src/lib/cn.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
11
frontend/src/main.tsx
Normal file
11
frontend/src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
|
||||||
|
import { App } from './app/App'
|
||||||
|
import './styles/globals.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
)
|
||||||
30
frontend/src/styles/globals.css
Normal file
30
frontend/src/styles/globals.css
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 210 20% 98%;
|
||||||
|
--foreground: 222 25% 12%;
|
||||||
|
--surface: 0 0% 100%;
|
||||||
|
--muted: 210 18% 94%;
|
||||||
|
--muted-foreground: 215 12% 42%;
|
||||||
|
--border: 214 18% 86%;
|
||||||
|
--primary: 213 52% 32%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--success: 151 55% 36%;
|
||||||
|
--warning: 38 86% 48%;
|
||||||
|
--danger: 0 70% 50%;
|
||||||
|
--info: 204 76% 44%;
|
||||||
|
--ai-accent: 186 52% 36%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: hsl(var(--background));
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
font-family:
|
||||||
|
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
frontend/tailwind.config.ts
Normal file
37
frontend/tailwind.config.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Config } from 'tailwindcss'
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
darkMode: ['class'],
|
||||||
|
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: 'hsl(var(--border))',
|
||||||
|
background: 'hsl(var(--background))',
|
||||||
|
foreground: 'hsl(var(--foreground))',
|
||||||
|
muted: {
|
||||||
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
|
foreground: 'hsl(var(--muted-foreground))'
|
||||||
|
},
|
||||||
|
surface: 'hsl(var(--surface))',
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
|
foreground: 'hsl(var(--primary-foreground))'
|
||||||
|
},
|
||||||
|
success: 'hsl(var(--success))',
|
||||||
|
warning: 'hsl(var(--warning))',
|
||||||
|
danger: 'hsl(var(--danger))',
|
||||||
|
info: 'hsl(var(--info))',
|
||||||
|
ai: 'hsl(var(--ai-accent))'
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: 'var(--radius)',
|
||||||
|
md: 'calc(var(--radius) - 2px)',
|
||||||
|
sm: 'calc(var(--radius) - 4px)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: [require('tailwindcss-animate')]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
25
frontend/tsconfig.json
Normal file
25
frontend/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ES2020"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
12
frontend/tsconfig.node.json
Normal file
12
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["DOM", "ES2020"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
15
frontend/vite.config.ts
Normal file
15
frontend/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173
|
||||||
|
}
|
||||||
|
})
|
||||||
13
infra/backend.Dockerfile
Normal file
13
infra/backend.Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY backend/pyproject.toml /app/pyproject.toml
|
||||||
|
RUN pip install --no-cache-dir -e ".[dev]"
|
||||||
|
|
||||||
|
COPY backend /app
|
||||||
|
|
||||||
|
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||||
72
infra/docker-compose.yml
Normal file
72
infra/docker-compose.yml
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: pgvector/pgvector:pg16
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-ai_ticket_platform}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-ai_ticket}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ai_ticket}
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minio}
|
||||||
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minio123}
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
- "9001:9001"
|
||||||
|
volumes:
|
||||||
|
- minio-data:/data
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: infra/backend.Dockerfile
|
||||||
|
env_file:
|
||||||
|
- path: ../.env
|
||||||
|
required: false
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
- redis
|
||||||
|
volumes:
|
||||||
|
- ../backend:/app
|
||||||
|
|
||||||
|
worker:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: infra/backend.Dockerfile
|
||||||
|
env_file:
|
||||||
|
- path: ../.env
|
||||||
|
required: false
|
||||||
|
command: celery -A config.celery worker -l info
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
- redis
|
||||||
|
volumes:
|
||||||
|
- ../backend:/app
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: infra/frontend.Dockerfile
|
||||||
|
ports:
|
||||||
|
- "5173:5173"
|
||||||
|
volumes:
|
||||||
|
- ../frontend:/app
|
||||||
|
- frontend-node-modules:/app/node_modules
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres-data:
|
||||||
|
minio-data:
|
||||||
|
frontend-node-modules:
|
||||||
10
infra/frontend.Dockerfile
Normal file
10
infra/frontend.Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
FROM node:22-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY frontend/package.json /app/package.json
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY frontend /app
|
||||||
|
|
||||||
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||||
Reference in New Issue
Block a user