feat(demo): 初始化审核智能体演示基线
This commit is contained in:
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
.env
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.sqlite3
|
||||
db.sqlite3
|
||||
staticfiles/
|
||||
media/
|
||||
.pytest_cache/
|
||||
.idea/
|
||||
20
README.md
Normal file
20
README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# DEMO-AGENT V2
|
||||
|
||||
V2 是一个重置后的最小 Django 项目,仅保留基础配置和登录页面。
|
||||
|
||||
## 本地运行
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
.venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
python manage.py migrate
|
||||
python manage.py createsuperuser
|
||||
python manage.py runserver
|
||||
```
|
||||
|
||||
访问:
|
||||
|
||||
- 登录页:http://127.0.0.1:8000/login/
|
||||
- 首页:http://127.0.0.1:8000/
|
||||
- 管理后台:http://127.0.0.1:8000/admin/
|
||||
1
config/__init__.py
Normal file
1
config/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
8
config/asgi.py
Normal file
8
config/asgi.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""ASGI config for the project."""
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
||||
|
||||
application = get_asgi_application()
|
||||
104
config/settings.py
Normal file
104
config/settings.py
Normal file
@@ -0,0 +1,104 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def load_env_file(file_path: Path) -> None:
|
||||
"""Loads a simple KEY=VALUE .env file into process env without extra deps."""
|
||||
|
||||
if not file_path.exists():
|
||||
return
|
||||
|
||||
for raw_line in file_path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
os.environ.setdefault(key.strip(), value.strip())
|
||||
|
||||
|
||||
load_env_file(BASE_DIR / ".env")
|
||||
|
||||
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "django-insecure-v2-local-development-key")
|
||||
DEBUG = os.environ.get("DJANGO_DEBUG", "true").lower() == "true"
|
||||
ALLOWED_HOSTS = [
|
||||
host.strip()
|
||||
for host in os.environ.get(
|
||||
"DJANGO_ALLOWED_HOSTS",
|
||||
"127.0.0.1,localhost,testserver",
|
||||
).split(",")
|
||||
if host.strip()
|
||||
]
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"review_agent",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"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": [BASE_DIR / "templates"],
|
||||
"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"
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": BASE_DIR / "db.sqlite3",
|
||||
}
|
||||
}
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
|
||||
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
|
||||
]
|
||||
|
||||
LANGUAGE_CODE = "zh-hans"
|
||||
TIME_ZONE = "Asia/Shanghai"
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
STATIC_URL = "static/"
|
||||
STATICFILES_DIRS = [BASE_DIR / "static"]
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
LOGIN_URL = "login"
|
||||
LOGIN_REDIRECT_URL = "home"
|
||||
LOGOUT_REDIRECT_URL = "login"
|
||||
|
||||
LLM_API_KEY = os.environ.get("LLM_API_KEY", "")
|
||||
LLM_BASE_URL = os.environ.get("LLM_BASE_URL", "https://api.siliconflow.cn/v1")
|
||||
LLM_MODEL = os.environ.get("LLM_MODEL", "")
|
||||
27
config/urls.py
Normal file
27
config/urls.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.views import LoginView, LogoutView, PasswordChangeView
|
||||
from django.urls import path
|
||||
|
||||
from review_agent.views import workspace
|
||||
|
||||
urlpatterns = [
|
||||
path("", workspace, name="home"),
|
||||
path(
|
||||
"login/",
|
||||
LoginView.as_view(
|
||||
template_name="registration/login.html",
|
||||
redirect_authenticated_user=True,
|
||||
),
|
||||
name="login",
|
||||
),
|
||||
path("logout/", LogoutView.as_view(), name="logout"),
|
||||
path(
|
||||
"password/change/",
|
||||
PasswordChangeView.as_view(
|
||||
template_name="registration/password_change.html",
|
||||
success_url="/",
|
||||
),
|
||||
name="password_change",
|
||||
),
|
||||
path("admin/", admin.site.urls),
|
||||
]
|
||||
8
config/wsgi.py
Normal file
8
config/wsgi.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""WSGI config for the project."""
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
||||
|
||||
application = get_wsgi_application()
|
||||
19
manage.py
Normal file
19
manage.py
Normal file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Is it installed and available on your PYTHONPATH?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
Django>=5.0,<6.0
|
||||
1
review_agent/__init__.py
Normal file
1
review_agent/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
7
review_agent/apps.py
Normal file
7
review_agent/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ReviewAgentConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "review_agent"
|
||||
verbose_name = "审核智能体"
|
||||
79
review_agent/llm.py
Normal file
79
review_agent/llm.py
Normal file
@@ -0,0 +1,79 @@
|
||||
import json
|
||||
from urllib import error, request
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class LLMConfigurationError(RuntimeError):
|
||||
"""Raised when the project has not been configured with a usable LLM provider."""
|
||||
|
||||
|
||||
class LLMRequestError(RuntimeError):
|
||||
"""Raised when the remote LLM provider call fails."""
|
||||
|
||||
|
||||
def generate_reply(conversation, user_message: str) -> str:
|
||||
"""Calls the SiliconFlow OpenAI-compatible chat endpoint and returns assistant text."""
|
||||
|
||||
if not settings.LLM_API_KEY:
|
||||
raise LLMConfigurationError("缺少 LLM_API_KEY 配置。")
|
||||
if not settings.LLM_MODEL:
|
||||
raise LLMConfigurationError("缺少 LLM_MODEL 配置。")
|
||||
|
||||
payload = {
|
||||
"model": settings.LLM_MODEL,
|
||||
"messages": build_messages(conversation, user_message),
|
||||
"temperature": 0.3,
|
||||
}
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
endpoint = f"{settings.LLM_BASE_URL.rstrip('/')}/chat/completions"
|
||||
|
||||
http_request = request.Request(
|
||||
endpoint,
|
||||
data=body,
|
||||
headers={
|
||||
"Authorization": f"Bearer {settings.LLM_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
with request.urlopen(http_request, timeout=60) as response:
|
||||
data = json.loads(response.read().decode("utf-8"))
|
||||
except error.HTTPError as exc:
|
||||
details = exc.read().decode("utf-8", errors="ignore")
|
||||
raise LLMRequestError(f"模型接口调用失败:HTTP {exc.code} {details}") from exc
|
||||
except error.URLError as exc:
|
||||
raise LLMRequestError(f"模型接口调用失败:{exc.reason}") from exc
|
||||
|
||||
try:
|
||||
return data["choices"][0]["message"]["content"].strip()
|
||||
except (KeyError, IndexError, TypeError) as exc:
|
||||
raise LLMRequestError("模型接口返回格式不符合预期。") from exc
|
||||
|
||||
|
||||
def build_messages(conversation, latest_user_message: str) -> list[dict[str, str]]:
|
||||
"""Builds system and conversation history messages for the provider call."""
|
||||
|
||||
messages = [{"role": "system", "content": system_prompt()}]
|
||||
|
||||
for message in conversation.messages.all():
|
||||
messages.append({"role": message.role, "content": message.content})
|
||||
|
||||
if not conversation.messages.filter(role="user", content=latest_user_message.strip()).exists():
|
||||
messages.append({"role": "user", "content": latest_user_message.strip()})
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
def system_prompt() -> str:
|
||||
"""Defines the initial assistant behavior for the review workspace."""
|
||||
|
||||
return (
|
||||
"你是“审核智能体”,服务于体外诊断试剂临床注册文件准备与审核场景。"
|
||||
"你的回答要专业、简洁、结构清楚,优先帮助用户完成法规核查、说明书审核、"
|
||||
"风险识别、资料补充建议和审评思路梳理。"
|
||||
"当信息不足时,明确指出缺失信息,并给出下一步建议。"
|
||||
"除非用户明确要求英文,否则始终使用中文回答。"
|
||||
)
|
||||
78
review_agent/migrations/0001_initial.py
Normal file
78
review_agent/migrations/0001_initial.py
Normal file
@@ -0,0 +1,78 @@
|
||||
# Generated by Django 5.2.14 on 2026-06-04 15:27
|
||||
|
||||
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="Conversation",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("title", models.CharField(blank=True, max_length=120)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="review_conversations",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-updated_at", "-id"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Message",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"role",
|
||||
models.CharField(
|
||||
choices=[("user", "用户"), ("assistant", "助手")], max_length=20
|
||||
),
|
||||
),
|
||||
("content", models.TextField()),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"conversation",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="messages",
|
||||
to="review_agent.conversation",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["created_at", "id"],
|
||||
},
|
||||
),
|
||||
]
|
||||
1
review_agent/migrations/__init__.py
Normal file
1
review_agent/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
44
review_agent/models.py
Normal file
44
review_agent/models.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Conversation(models.Model):
|
||||
"""Stores a user's review-agent conversation shell."""
|
||||
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="review_conversations",
|
||||
)
|
||||
title = models.CharField(max_length=120, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-updated_at", "-id"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.title or f"对话 {self.pk}"
|
||||
|
||||
|
||||
class Message(models.Model):
|
||||
"""Stores one user or assistant message in a conversation."""
|
||||
|
||||
class Role(models.TextChoices):
|
||||
USER = "user", "用户"
|
||||
ASSISTANT = "assistant", "助手"
|
||||
|
||||
conversation = models.ForeignKey(
|
||||
Conversation,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="messages",
|
||||
)
|
||||
role = models.CharField(max_length=20, choices=Role.choices)
|
||||
content = models.TextField()
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["created_at", "id"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.get_role_display()} - {self.conversation_id}"
|
||||
90
review_agent/services.py
Normal file
90
review_agent/services.py
Normal file
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from django.db.models import Q, QuerySet
|
||||
from django.utils import timezone
|
||||
|
||||
from .llm import LLMConfigurationError, LLMRequestError, generate_reply
|
||||
from .models import Conversation, Message
|
||||
|
||||
|
||||
def list_conversations(user, search: str = "") -> QuerySet[Conversation]:
|
||||
"""Returns a user's conversations, optionally filtered by title or content."""
|
||||
|
||||
conversations = Conversation.objects.filter(user=user)
|
||||
if not search:
|
||||
return conversations
|
||||
|
||||
return conversations.filter(
|
||||
Q(title__icontains=search) | Q(messages__content__icontains=search)
|
||||
).distinct()
|
||||
|
||||
|
||||
def get_conversation_for_user(user, conversation_id: int | None) -> Conversation | None:
|
||||
"""Loads a conversation only when it belongs to the current user."""
|
||||
|
||||
if not conversation_id:
|
||||
return None
|
||||
return Conversation.objects.filter(user=user, pk=conversation_id).first()
|
||||
|
||||
|
||||
def create_conversation(user) -> Conversation:
|
||||
"""Creates an empty conversation that can immediately accept messages."""
|
||||
|
||||
now = timezone.localtime()
|
||||
return Conversation.objects.create(
|
||||
user=user,
|
||||
title=f"新对话 {now.strftime('%m-%d %H:%M')}",
|
||||
)
|
||||
|
||||
|
||||
def append_user_message(conversation: Conversation, content: str) -> Message:
|
||||
"""Appends a user message and updates the conversation title from the first prompt."""
|
||||
|
||||
message = Message.objects.create(
|
||||
conversation=conversation,
|
||||
role=Message.Role.USER,
|
||||
content=content.strip(),
|
||||
)
|
||||
|
||||
if conversation.messages.filter(role=Message.Role.USER).count() == 1:
|
||||
conversation.title = build_conversation_title(content)
|
||||
conversation.save(update_fields=["title", "updated_at"])
|
||||
|
||||
return message
|
||||
|
||||
|
||||
def append_assistant_message(conversation: Conversation, content: str) -> Message:
|
||||
"""Appends the deterministic assistant reply."""
|
||||
|
||||
return Message.objects.create(
|
||||
conversation=conversation,
|
||||
role=Message.Role.ASSISTANT,
|
||||
content=content,
|
||||
)
|
||||
|
||||
|
||||
def send_message(conversation: Conversation, content: str) -> tuple[Message, Message]:
|
||||
"""Stores one user message and one provider-backed assistant reply."""
|
||||
|
||||
user_message = append_user_message(conversation, content)
|
||||
try:
|
||||
reply_content = generate_reply(conversation, content)
|
||||
except (LLMConfigurationError, LLMRequestError) as exc:
|
||||
reply_content = f"模型调用失败:{exc}"
|
||||
|
||||
assistant_message = append_assistant_message(conversation, reply_content)
|
||||
|
||||
if conversation.title.startswith("新对话"):
|
||||
conversation.title = build_conversation_title(content)
|
||||
conversation.save(update_fields=["title", "updated_at"])
|
||||
|
||||
return user_message, assistant_message
|
||||
|
||||
|
||||
def build_conversation_title(content: str) -> str:
|
||||
"""Creates a concise title from the first user message."""
|
||||
|
||||
normalized = " ".join(content.strip().split())
|
||||
if not normalized:
|
||||
return "新对话"
|
||||
return normalized[:24]
|
||||
47
review_agent/views.py
Normal file
47
review_agent/views.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import redirect, render
|
||||
from django.views.decorators.http import require_http_methods
|
||||
|
||||
from .services import create_conversation, get_conversation_for_user, list_conversations, send_message
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET", "POST"])
|
||||
def workspace(request: HttpRequest) -> HttpResponse:
|
||||
"""Renders the review-agent workspace and handles conversation actions."""
|
||||
|
||||
if request.method == "POST":
|
||||
action = request.POST.get("action")
|
||||
conversation = get_conversation_for_user(request.user, request.POST.get("conversation_id"))
|
||||
|
||||
if action == "new_conversation":
|
||||
conversation = create_conversation(request.user)
|
||||
return redirect(f"/?conversation={conversation.pk}")
|
||||
|
||||
if action == "send_message":
|
||||
content = (request.POST.get("prompt") or "").strip()
|
||||
if not conversation:
|
||||
conversation = create_conversation(request.user)
|
||||
if content:
|
||||
send_message(conversation, content)
|
||||
return redirect(f"/?conversation={conversation.pk}")
|
||||
|
||||
search = (request.GET.get("q") or "").strip()
|
||||
conversations = list_conversations(request.user, search)
|
||||
current = get_conversation_for_user(request.user, request.GET.get("conversation"))
|
||||
|
||||
if current is None and conversations.exists():
|
||||
current = conversations.first()
|
||||
|
||||
return render(
|
||||
request,
|
||||
"home.html",
|
||||
{
|
||||
"page_title": "审核智能体",
|
||||
"search_query": search,
|
||||
"conversations": conversations,
|
||||
"current_conversation": current,
|
||||
"messages": current.messages.all() if current else [],
|
||||
},
|
||||
)
|
||||
741
static/css/login.css
Normal file
741
static/css/login.css
Normal file
@@ -0,0 +1,741 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f5f7fb;
|
||||
--bg-strong: #edf2f7;
|
||||
--panel: #ffffff;
|
||||
--panel-soft: #f9fbff;
|
||||
--panel-muted: #eef4ff;
|
||||
--text: #1f2a37;
|
||||
--muted: #7b8794;
|
||||
--line: #e5eaf1;
|
||||
--line-strong: #d7e0ea;
|
||||
--accent: #3a72d8;
|
||||
--accent-dark: #2f5fbb;
|
||||
--sidebar: #f7faff;
|
||||
--sidebar-strong: #eef4fb;
|
||||
--danger-bg: #fff1f2;
|
||||
--danger-text: #be123c;
|
||||
--shadow: 0 12px 32px rgba(31, 42, 55, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: linear-gradient(180deg, #fbfcfe 0%, #f5f8fc 100%);
|
||||
color: var(--text);
|
||||
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
.login-page,
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 32px 16px;
|
||||
}
|
||||
|
||||
.login-card,
|
||||
.panel {
|
||||
width: min(100%, 420px);
|
||||
padding: 32px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 10px;
|
||||
color: var(--accent);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.muted {
|
||||
margin: 10px 0 24px;
|
||||
color: var(--muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.alert {
|
||||
margin-bottom: 18px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
background: var(--danger-bg);
|
||||
color: var(--danger-text);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
form {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
label {
|
||||
color: #344054;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"] {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.14);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.button {
|
||||
height: 44px;
|
||||
margin-top: 8px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: var(--accent-dark);
|
||||
}
|
||||
|
||||
.app-body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: 296px minmax(0, 1fr);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
padding: 18px;
|
||||
background: linear-gradient(180deg, var(--sidebar) 0%, var(--sidebar-strong) 100%);
|
||||
border-right: 1px solid var(--line);
|
||||
transition: width 180ms ease, padding 180ms ease, transform 180ms ease;
|
||||
}
|
||||
|
||||
.sidebar-top {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.brand-copy {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.brand-mark,
|
||||
.avatar,
|
||||
.message-avatar {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 11px;
|
||||
background: #e4edff;
|
||||
color: var(--accent);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-button span {
|
||||
width: 16px;
|
||||
height: 2px;
|
||||
margin-left: 11px;
|
||||
border-radius: 999px;
|
||||
background: #6b7785;
|
||||
}
|
||||
|
||||
.new-chat,
|
||||
.ghost-button,
|
||||
.send-button,
|
||||
.tool-chip {
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.new-chat {
|
||||
height: 44px;
|
||||
width: 100%;
|
||||
background: var(--accent);
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.search-form input {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.sidebar-label {
|
||||
margin: 0 0 10px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
.history-item.active,
|
||||
.history-item:hover {
|
||||
border-color: #cfdcf6;
|
||||
background: #edf4ff;
|
||||
}
|
||||
|
||||
.history-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.history-meta {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.history-empty {
|
||||
padding: 16px 14px;
|
||||
border: 1px dashed var(--line-strong);
|
||||
border-radius: 14px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.history-empty p,
|
||||
.history-empty span {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.history-empty span {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.user-card,
|
||||
.user-menu-trigger,
|
||||
.user-copy {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.user-copy strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-copy span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chat-shell {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 0 24px;
|
||||
min-height: 60px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.topbar-left,
|
||||
.topbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tabbar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
height: 60px;
|
||||
padding: 0 20px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.avatar.large {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.user-menu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.user-menu-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 44px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.user-menu-trigger:hover,
|
||||
.user-menu.open .user-menu-trigger {
|
||||
border-color: var(--line);
|
||||
background: #f9fbff;
|
||||
}
|
||||
|
||||
.caret {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-left: 4px;
|
||||
border-right: 1.5px solid #7b8794;
|
||||
border-bottom: 1.5px solid #7b8794;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 10px);
|
||||
right: 0;
|
||||
min-width: 184px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--shadow);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-6px);
|
||||
transition: opacity 140ms ease, transform 140ms ease, visibility 140ms ease;
|
||||
}
|
||||
|
||||
.user-menu.open .user-dropdown {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.user-dropdown-section {
|
||||
padding: 10px 12px 8px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.user-dropdown-label {
|
||||
margin: 0 0 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.user-dropdown-name {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-dropdown-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.user-dropdown-link:hover {
|
||||
background: #f5f8fc;
|
||||
}
|
||||
|
||||
.user-dropdown-form {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.danger-link {
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.chat-stage {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
min-height: 0;
|
||||
background: #ffffff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-scroll {
|
||||
min-height: 0;
|
||||
padding: 32px min(6vw, 64px) 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.conversation-header,
|
||||
.empty-state {
|
||||
max-width: 920px;
|
||||
margin: 0 auto 28px;
|
||||
}
|
||||
|
||||
.conversation-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.conversation-header h1,
|
||||
.empty-state h1 {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.conversation-meta {
|
||||
padding-top: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
max-width: 860px;
|
||||
margin: 0 auto 18px;
|
||||
}
|
||||
|
||||
.message.user {
|
||||
grid-template-columns: minmax(0, 1fr) 44px;
|
||||
}
|
||||
|
||||
.message.user .message-bubble {
|
||||
order: -1;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
padding: 18px 20px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: #f8fbff;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.message-bubble p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.user-mark {
|
||||
background: #dbe7ff;
|
||||
}
|
||||
|
||||
.composer-wrap {
|
||||
padding: 18px 24px 24px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.composer {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 24px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 8px 24px rgba(31, 42, 55, 0.06);
|
||||
}
|
||||
|
||||
.composer textarea {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
max-height: 180px;
|
||||
resize: vertical;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
line-height: 1.7;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.composer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.composer-tools {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tool-chip {
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
background: var(--panel-muted);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.passive-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.send-button {
|
||||
height: 40px;
|
||||
min-width: 88px;
|
||||
padding: 0 18px;
|
||||
background: var(--accent);
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.workspace[data-sidebar-state="collapsed"] {
|
||||
grid-template-columns: 88px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.workspace[data-sidebar-state="collapsed"] .brand-text,
|
||||
.workspace[data-sidebar-state="collapsed"] .brand-subtitle,
|
||||
.workspace[data-sidebar-state="collapsed"] .new-chat,
|
||||
.workspace[data-sidebar-state="collapsed"] .search-form,
|
||||
.workspace[data-sidebar-state="collapsed"] .sidebar-label,
|
||||
.workspace[data-sidebar-state="collapsed"] .history-title,
|
||||
.workspace[data-sidebar-state="collapsed"] .history-meta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workspace[data-sidebar-state="collapsed"] .history-item {
|
||||
place-items: center;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.workspace[data-sidebar-state="collapsed"] .sidebar {
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.workspace {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
inset: 0 auto 0 0;
|
||||
width: 280px;
|
||||
z-index: 20;
|
||||
box-shadow: var(--shadow);
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.workspace[data-sidebar-state="open"] .sidebar {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.chat-shell {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.mobile-toggle {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.chat-scroll,
|
||||
.composer-wrap {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
min-height: auto;
|
||||
padding-top: 12px;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.topbar-right {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.conversation-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.tabbar {
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.user-card {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-menu-trigger {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-copy strong,
|
||||
.user-copy span {
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.message,
|
||||
.message.user {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.composer-actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.composer-tools {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.send-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
59
static/js/app.js
Normal file
59
static/js/app.js
Normal file
@@ -0,0 +1,59 @@
|
||||
(function () {
|
||||
var workspace = document.querySelector(".workspace");
|
||||
var sidebarToggle = document.getElementById("sidebarToggle");
|
||||
var mobileSidebarToggle = document.getElementById("mobileSidebarToggle");
|
||||
var userMenu = document.getElementById("userMenu");
|
||||
var userMenuTrigger = document.getElementById("userMenuTrigger");
|
||||
|
||||
if (!workspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
function isMobile() {
|
||||
return window.matchMedia("(max-width: 980px)").matches;
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
var state = workspace.getAttribute("data-sidebar-state");
|
||||
if (isMobile()) {
|
||||
workspace.setAttribute("data-sidebar-state", state === "open" ? "closed" : "open");
|
||||
return;
|
||||
}
|
||||
workspace.setAttribute("data-sidebar-state", state === "collapsed" ? "open" : "collapsed");
|
||||
}
|
||||
|
||||
function syncSidebarState() {
|
||||
if (isMobile()) {
|
||||
if (workspace.getAttribute("data-sidebar-state") === "collapsed") {
|
||||
workspace.setAttribute("data-sidebar-state", "closed");
|
||||
}
|
||||
} else if (workspace.getAttribute("data-sidebar-state") === "closed") {
|
||||
workspace.setAttribute("data-sidebar-state", "open");
|
||||
}
|
||||
}
|
||||
|
||||
if (sidebarToggle) {
|
||||
sidebarToggle.addEventListener("click", toggleSidebar);
|
||||
}
|
||||
|
||||
if (mobileSidebarToggle) {
|
||||
mobileSidebarToggle.addEventListener("click", toggleSidebar);
|
||||
}
|
||||
|
||||
if (userMenu && userMenuTrigger) {
|
||||
userMenuTrigger.addEventListener("click", function () {
|
||||
var isOpen = userMenu.classList.toggle("open");
|
||||
userMenuTrigger.setAttribute("aria-expanded", isOpen ? "true" : "false");
|
||||
});
|
||||
|
||||
document.addEventListener("click", function (event) {
|
||||
if (!userMenu.contains(event.target)) {
|
||||
userMenu.classList.remove("open");
|
||||
userMenuTrigger.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener("resize", syncSidebarState);
|
||||
syncSidebarState();
|
||||
})();
|
||||
14
templates/base.html
Normal file
14
templates/base.html
Normal file
@@ -0,0 +1,14 @@
|
||||
{% load static %}
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}DEMO-AGENT V2{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{% static 'css/login.css' %}">
|
||||
</head>
|
||||
<body class="{% block body_class %}{% endblock %}">
|
||||
{% block content %}{% endblock %}
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
151
templates/home.html
Normal file
151
templates/home.html
Normal file
@@ -0,0 +1,151 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}审核智能体 - DEMO-AGENT V2{% endblock %}
|
||||
{% block body_class %}app-body{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="workspace" data-sidebar-state="open">
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-top">
|
||||
<button class="icon-button sidebar-toggle" type="button" id="sidebarToggle" aria-label="折叠侧边栏">
|
||||
<span></span>
|
||||
<span></span>
|
||||
</button>
|
||||
<div class="brand">
|
||||
<span class="brand-mark">审</span>
|
||||
<div class="brand-copy">
|
||||
<strong class="brand-text">审核智能体</strong>
|
||||
<span class="brand-subtitle">临床注册文件审核工作台</span>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="new_conversation">
|
||||
<button class="new-chat" type="submit">+ 新对话</button>
|
||||
</form>
|
||||
<form class="search-form" method="get">
|
||||
<label class="sr-only" for="conversationSearch">搜索会话</label>
|
||||
<input id="conversationSearch" type="text" name="q" value="{{ search_query }}" placeholder="搜索会话...">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-group">
|
||||
<p class="sidebar-label">对话记录</p>
|
||||
<nav class="history-list" aria-label="对话历史">
|
||||
{% for conversation in conversations %}
|
||||
<a
|
||||
class="history-item{% if current_conversation and current_conversation.pk == conversation.pk %} active{% endif %}"
|
||||
href="/?conversation={{ conversation.pk }}{% if search_query %}&q={{ search_query|urlencode }}{% endif %}"
|
||||
>
|
||||
<span class="history-title">{{ conversation.title|default:"新对话" }}</span>
|
||||
<span class="history-meta">{{ conversation.updated_at|date:"m月d日 H:i" }}</span>
|
||||
</a>
|
||||
{% empty %}
|
||||
<div class="history-empty">
|
||||
<p>暂无会话记录</p>
|
||||
<span>点击上方“新对话”开始审核。</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
|
||||
<section class="chat-shell">
|
||||
<header class="topbar">
|
||||
<div class="topbar-left">
|
||||
<button class="icon-button mobile-toggle" type="button" id="mobileSidebarToggle" aria-label="展开侧边栏">
|
||||
<span></span>
|
||||
<span></span>
|
||||
</button>
|
||||
<div class="tabbar" role="tablist" aria-label="页面切换">
|
||||
<button class="tab" type="button" role="tab" aria-selected="false">首页</button>
|
||||
<button class="tab" type="button" role="tab" aria-selected="false">知识库管理</button>
|
||||
<button class="tab active" type="button" role="tab" aria-selected="true">审核智能体</button>
|
||||
<button class="tab" type="button" role="tab" aria-selected="false">视频实时监测</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="topbar-right">
|
||||
<div class="user-menu" id="userMenu">
|
||||
<button class="user-menu-trigger" id="userMenuTrigger" type="button" aria-haspopup="menu" aria-expanded="false">
|
||||
<span class="avatar large">{{ request.user.username|slice:":1"|upper }}</span>
|
||||
<div class="user-copy">
|
||||
<strong>{{ request.user.username }}</strong>
|
||||
<span>当前登录用户</span>
|
||||
</div>
|
||||
<span class="caret"></span>
|
||||
</button>
|
||||
<div class="user-dropdown" id="userDropdown" role="menu">
|
||||
<div class="user-dropdown-section" role="none">
|
||||
<p class="user-dropdown-label">用户信息</p>
|
||||
<strong class="user-dropdown-name">{{ request.user.username }}</strong>
|
||||
</div>
|
||||
<a class="user-dropdown-link" href="{% url 'password_change' %}" role="menuitem">修改密码</a>
|
||||
<form action="{% url 'logout' %}" method="post" class="user-dropdown-form" role="none">
|
||||
{% csrf_token %}
|
||||
<button class="user-dropdown-link danger-link" type="submit" role="menuitem">退出登录</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="chat-stage">
|
||||
<div class="chat-scroll">
|
||||
{% if current_conversation %}
|
||||
<div class="conversation-header">
|
||||
<div>
|
||||
<p class="eyebrow">审核智能体</p>
|
||||
<h1>{{ current_conversation.title|default:"新对话" }}</h1>
|
||||
</div>
|
||||
<span class="conversation-meta">最后更新 {{ current_conversation.updated_at|date:"Y-m-d H:i" }}</span>
|
||||
</div>
|
||||
|
||||
{% for message in messages %}
|
||||
<article class="message {{ message.role }}">
|
||||
<div class="message-avatar{% if message.role == 'user' %} user-mark{% endif %}">
|
||||
{% if message.role == "assistant" %}AI{% else %}{{ request.user.username|slice:":1"|upper }}{% endif %}
|
||||
</div>
|
||||
<div class="message-bubble">
|
||||
<p>{{ message.content|linebreaksbr }}</p>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p class="eyebrow">审核智能体</p>
|
||||
<h1>开始新的审核对话</h1>
|
||||
<p class="muted">输入资料疑点、法规条款、说明书问题或风险项,系统会为你保留真实会话记录。</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="composer-wrap">
|
||||
<form class="composer" action="/" method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="send_message">
|
||||
{% if current_conversation %}
|
||||
<input type="hidden" name="conversation_id" value="{{ current_conversation.pk }}">
|
||||
{% endif %}
|
||||
<label class="sr-only" for="prompt">输入消息</label>
|
||||
<textarea id="prompt" name="prompt" rows="1" placeholder="输入审核问题、法规条款、说明书疑点或上传需求"></textarea>
|
||||
<div class="composer-actions">
|
||||
<div class="composer-tools">
|
||||
<span class="tool-chip passive-chip">法规核查</span>
|
||||
<span class="tool-chip passive-chip">说明书审核</span>
|
||||
<span class="tool-chip passive-chip">风险识别</span>
|
||||
</div>
|
||||
<button class="send-button" type="submit">发送</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{% static 'js/app.js' %}"></script>
|
||||
{% endblock %}
|
||||
30
templates/registration/login.html
Normal file
30
templates/registration/login.html
Normal file
@@ -0,0 +1,30 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}登录 - DEMO-AGENT V2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="login-page">
|
||||
<section class="login-card" aria-labelledby="login-title">
|
||||
<p class="eyebrow">DEMO-AGENT V2</p>
|
||||
<h1 id="login-title">登录系统</h1>
|
||||
<p class="muted">请输入账号和密码进入 Django 基础后台。</p>
|
||||
|
||||
{% if form.errors %}
|
||||
<div class="alert" role="alert">用户名或密码不正确,请重新输入。</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
|
||||
<label for="{{ form.username.id_for_label }}">用户名</label>
|
||||
{{ form.username }}
|
||||
|
||||
<label for="{{ form.password.id_for_label }}">密码</label>
|
||||
{{ form.password }}
|
||||
|
||||
<button class="button" type="submit">登录</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
31
templates/registration/password_change.html
Normal file
31
templates/registration/password_change.html
Normal file
@@ -0,0 +1,31 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}修改密码 - DEMO-AGENT V2{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main class="login-page">
|
||||
<section class="login-card">
|
||||
<p class="eyebrow">审核智能体</p>
|
||||
<h1>修改密码</h1>
|
||||
<p class="muted">输入当前密码,并设置新的登录密码。</p>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert" role="alert">{{ form.non_field_errors }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% for field in form %}
|
||||
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
|
||||
{{ field }}
|
||||
{% if field.errors %}
|
||||
<div class="alert" role="alert">{{ field.errors }}</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
<button class="button" type="submit">保存密码</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user