feat: add stage 0 foundation

This commit is contained in:
hailin
2026-06-30 15:55:46 +08:00
parent 6d373da09f
commit 604f456e03
68 changed files with 4884 additions and 0 deletions

16
.env.example Normal file
View 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
View File

@@ -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
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,3 @@
from ninja import Router
router = Router(tags=["accounts"])

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.accounts"

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -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."))

View File

@@ -0,0 +1 @@
# Use Django's built-in User, Group, and Permission models for MVP RBAC.

View 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",
),
}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View 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)

View File

@@ -0,0 +1 @@

26
backend/apps/audit/api.py Normal file
View 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]

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AuditConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.audit"

View 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'),
],
},
),
]

View 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}"

View File

@@ -0,0 +1 @@

View 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"

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,8 @@
from ninja import Router
router = Router(tags=["system"])
@router.get("/health")
def health(request):
return {"status": "ok"}

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class SystemConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.system"

View File

@@ -0,0 +1 @@

View 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"}

View File

@@ -0,0 +1 @@

7
backend/config/asgi.py Normal file
View 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
View 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()

View File

@@ -0,0 +1 @@

View 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")

View File

@@ -0,0 +1,3 @@
from .base import * # noqa: F403
DEBUG = True

View 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
View 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
View 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
View 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
View 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"

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View 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
```

View 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
View 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

File diff suppressed because it is too large Load Diff

34
frontend/package.json Normal file
View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
}

5
frontend/src/app/App.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { DesignSystemPreview } from '@/features/design-system/DesignSystemPreview'
export function App() {
return <DesignSystemPreview />
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -0,0 +1,5 @@
import { App } from './App'
export function Router() {
return <App />
}

View 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>
)
}

View 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>
}

View 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>
}

View 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} />
}

View 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'

View 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} />
}

View 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
View 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
View 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>
)

View 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;
}
}

View 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
View 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" }]
}

View 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
View 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
View 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
View 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
View 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"]