feat: complete SOP execution workflow
This commit is contained in:
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
bin
|
||||
web/dist
|
||||
web/node_modules
|
||||
*.log
|
||||
.DS_Store
|
||||
10
.env.example
Normal file
10
.env.example
Normal file
@@ -0,0 +1,10 @@
|
||||
APP_HTTP_PORT=18090
|
||||
GOPROXY=https://goproxy.cn,direct
|
||||
APP_AUTH_JWT_SECRET=replace-with-at-least-32-random-characters
|
||||
APP_SEED_ADMIN_USERNAME=admin
|
||||
APP_SEED_ADMIN_PASSWORD=replace-with-a-strong-initial-password
|
||||
APP_SEED_ADMIN_DISPLAY_NAME=平台管理员
|
||||
MYSQL_DATABASE=iqudo_top1
|
||||
MYSQL_USER=iqudo
|
||||
MYSQL_PASSWORD=replace-with-a-strong-database-password
|
||||
MYSQL_ROOT_PASSWORD=replace-with-a-different-root-password
|
||||
30
Dockerfile
Normal file
30
Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
||||
FROM node:22-alpine AS web-builder
|
||||
|
||||
WORKDIR /src/web
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY web/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM golang:1.24-alpine AS go-builder
|
||||
|
||||
ARG GOPROXY=https://goproxy.cn,direct
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN GOPROXY=${GOPROXY} go mod download
|
||||
COPY . ./
|
||||
COPY --from=web-builder /src/web/dist ./web/dist
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/iqudo-top1 .
|
||||
|
||||
FROM alpine:3.22
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata \
|
||||
&& addgroup -S app \
|
||||
&& adduser -S -G app app
|
||||
WORKDIR /app
|
||||
COPY --from=go-builder /out/iqudo-top1 /app/iqudo-top1
|
||||
COPY configs/config.example.yml /app/configs/config.yml
|
||||
COPY configs/config.prod.yml /app/configs/config.prod.yml
|
||||
USER app
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/app/iqudo-top1", "-env", "prod", "-config-dir", "/app/configs"]
|
||||
14
Makefile
14
Makefile
@@ -1,6 +1,6 @@
|
||||
WEB_DIR := web
|
||||
|
||||
.PHONY: web-install web-build build test vet check seed-pet-doctor
|
||||
.PHONY: web-install web-build build test vet check seed-pet-doctor docker-config docker-up docker-down docker-logs
|
||||
|
||||
web-install:
|
||||
cd $(WEB_DIR) && npm install
|
||||
@@ -21,3 +21,15 @@ check: test vet
|
||||
|
||||
seed-pet-doctor:
|
||||
go run ./scripts/seed-pet-doctor
|
||||
|
||||
docker-config:
|
||||
docker compose config --quiet
|
||||
|
||||
docker-up:
|
||||
docker compose up --build -d
|
||||
|
||||
docker-down:
|
||||
docker compose down
|
||||
|
||||
docker-logs:
|
||||
docker compose logs -f app nginx
|
||||
|
||||
21
README.md
21
README.md
@@ -13,6 +13,21 @@ make build
|
||||
|
||||
默认配置位于 `configs/config.yml`,可以通过环境变量覆盖。
|
||||
|
||||
## Docker Compose 运行
|
||||
|
||||
Compose 会启动 MySQL、应用服务和 Nginx,默认通过 `18090` 端口访问:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# 修改 .env 中的数据库密码、JWT 密钥和初始管理员密码
|
||||
make docker-config
|
||||
make docker-up
|
||||
```
|
||||
|
||||
查看服务日志使用 `make docker-logs`,停止服务使用 `make docker-down`。MySQL 数据保存在 `mysql-data` 命名卷中,停止服务不会删除数据。
|
||||
|
||||
镜像构建默认使用 `.env` 中的 `GOPROXY`。当前模板使用适合国内网络的代理;其他网络环境可以改为 `https://proxy.golang.org,direct`。
|
||||
|
||||
## 宠物医生问诊问药示范场景
|
||||
|
||||
数据库迁移和管理员初始化完成后执行:
|
||||
@@ -24,8 +39,8 @@ make seed-pet-doctor
|
||||
该命令会幂等地创建或完善以下配置:
|
||||
|
||||
- 宠物医生问诊问药场景
|
||||
- 21 个动态问诊字段
|
||||
- 急症红旗征象和问诊用药安全原则知识卡
|
||||
- 包含普通问诊与急症转诊分支的已发布 SOP
|
||||
- 47 个动态问诊字段,覆盖咨询背景、宠物档案、主诉、生命状态、既往史和用药详情
|
||||
- 线上问诊边界、急症红旗、用药安全、观察与复诊 4 张知识卡
|
||||
- 包含普通问诊、急症转诊、常规用药咨询和用药风险转诊分支的已发布 SOP
|
||||
|
||||
示范场景是配置数据,通用流程引擎不包含宠物行业专用判断。
|
||||
|
||||
23
deploy/nginx.conf
Normal file
23
deploy/nginx.conf
Normal file
@@ -0,0 +1,23 @@
|
||||
upstream iqudo_app {
|
||||
server app:8080;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
client_max_body_size 1m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://iqudo_app;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Request-ID $request_id;
|
||||
proxy_connect_timeout 5s;
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
}
|
||||
65
docker-compose.yml
Normal file
65
docker-compose.yml
Normal file
@@ -0,0 +1,65 @@
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.4
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_DATABASE: ${MYSQL_DATABASE:-iqudo_top1}
|
||||
MYSQL_USER: ${MYSQL_USER:-iqudo}
|
||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD:?set MYSQL_PASSWORD in .env}
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?set MYSQL_ROOT_PASSWORD in .env}
|
||||
TZ: Asia/Shanghai
|
||||
command: ["--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci"]
|
||||
volumes:
|
||||
- mysql-data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$$MYSQL_ROOT_PASSWORD --silent"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 20
|
||||
start_period: 20s
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
GOPROXY: ${GOPROXY:-https://goproxy.cn,direct}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
APP_ENV: prod
|
||||
APP_SERVER_HOST: 0.0.0.0
|
||||
APP_SERVER_PORT: 8080
|
||||
APP_DATABASE_HOST: mysql
|
||||
APP_DATABASE_PORT: 3306
|
||||
APP_DATABASE_NAME: ${MYSQL_DATABASE:-iqudo_top1}
|
||||
APP_DATABASE_USER: ${MYSQL_USER:-iqudo}
|
||||
APP_DATABASE_PASSWORD: ${MYSQL_PASSWORD:?set MYSQL_PASSWORD in .env}
|
||||
APP_AUTH_JWT_SECRET: ${APP_AUTH_JWT_SECRET:?set APP_AUTH_JWT_SECRET in .env}
|
||||
APP_SEED_ADMIN_USERNAME: ${APP_SEED_ADMIN_USERNAME:-admin}
|
||||
APP_SEED_ADMIN_PASSWORD: ${APP_SEED_ADMIN_PASSWORD:?set APP_SEED_ADMIN_PASSWORD in .env}
|
||||
APP_SEED_ADMIN_DISPLAY_NAME: ${APP_SEED_ADMIN_DISPLAY_NAME:-平台管理员}
|
||||
TZ: Asia/Shanghai
|
||||
depends_on:
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
expose:
|
||||
- "8080"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:8080/api/v1/health"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
|
||||
nginx:
|
||||
image: nginx:1.27-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${APP_HTTP_PORT:-18090}:80"
|
||||
volumes:
|
||||
- ./deploy/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
mysql-data:
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -14,5 +15,21 @@ func Record(db *gorm.DB, principal auth.Principal, action, resource string, reso
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Create(&model.AuditLog{TenantID: principal.TenantID, UserID: principal.UserID, Action: action, Resource: resource, ResourceID: resourceID, Payload: datatypes.JSON(data)}).Error
|
||||
fields := []zap.Field{
|
||||
zap.Uint64("tenant_id", principal.TenantID),
|
||||
zap.Uint64("user_id", principal.UserID),
|
||||
zap.String("action", action),
|
||||
zap.String("resource", resource),
|
||||
zap.Uint64("resource_id", resourceID),
|
||||
}
|
||||
if err := db.Create(&model.AuditLog{TenantID: principal.TenantID, UserID: principal.UserID, Action: action, Resource: resource, ResourceID: resourceID, Payload: datatypes.JSON(data)}).Error; err != nil {
|
||||
zap.L().Error("persist business event", append(fields, zap.Error(err))...)
|
||||
return err
|
||||
}
|
||||
logBusinessEvent(fields)
|
||||
return nil
|
||||
}
|
||||
|
||||
func logBusinessEvent(fields []zap.Field) {
|
||||
zap.L().Info("business event", fields...)
|
||||
}
|
||||
|
||||
34
internal/audit/audit_test.go
Normal file
34
internal/audit/audit_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
)
|
||||
|
||||
func TestLogBusinessEventIncludesRequiredFields(t *testing.T) {
|
||||
core, logs := observer.New(zapcore.InfoLevel)
|
||||
undo := zap.ReplaceGlobals(zap.New(core))
|
||||
defer undo()
|
||||
|
||||
logBusinessEvent([]zap.Field{
|
||||
zap.Uint64("tenant_id", 1),
|
||||
zap.Uint64("user_id", 2),
|
||||
zap.String("action", "publish"),
|
||||
zap.String("resource", "sop"),
|
||||
zap.Uint64("resource_id", 3),
|
||||
})
|
||||
|
||||
entries := logs.All()
|
||||
if len(entries) != 1 || entries[0].Message != "business event" {
|
||||
t.Fatalf("entries = %+v, want one business event", entries)
|
||||
}
|
||||
fields := entries[0].ContextMap()
|
||||
for _, key := range []string{"tenant_id", "user_id", "action", "resource", "resource_id"} {
|
||||
if _, ok := fields[key]; !ok {
|
||||
t.Fatalf("required field %q is missing from business event", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@ func (s *Service) Refresh(rawToken string) (TokenPair, error) {
|
||||
return TokenPair{}, ErrInvalidCredentials
|
||||
}
|
||||
var user model.User
|
||||
if err := s.db.First(&user, stored.UserID).Error; err != nil {
|
||||
if err := s.db.Where("id = ? AND status = ?", stored.UserID, "active").First(&user).Error; err != nil {
|
||||
return TokenPair{}, err
|
||||
}
|
||||
principal, err := s.principalForTenant(user, stored.TenantID)
|
||||
|
||||
@@ -76,6 +76,7 @@ func New(db *gorm.DB, authService *auth.Service, frontend fs.FS, log *zap.Logger
|
||||
|
||||
protected.GET("/published-sops", middleware.RequirePermission("sop.execute"), runHandler.PublishedSOPs)
|
||||
protected.GET("/runs", middleware.RequireAnyPermission("runs.view_all", "runs.view_own"), runHandler.List)
|
||||
protected.GET("/runs/options", middleware.RequireAnyPermission("runs.view_all", "runs.view_own"), runHandler.Options)
|
||||
protected.POST("/runs", middleware.RequirePermission("sop.execute"), runHandler.Start)
|
||||
protected.GET("/runs/:id", middleware.RequireAnyPermission("runs.view_all", "runs.view_own"), runHandler.Get)
|
||||
protected.GET("/runs/:id/detail", middleware.RequireAnyPermission("runs.view_all", "runs.view_own"), runHandler.Detail)
|
||||
|
||||
@@ -113,10 +113,7 @@ func (h *Handler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
err = h.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&membership).Updates(map[string]interface{}{"role_id": role.ID, "status": input.Status}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.User{}).Where("id = ?", membership.UserID).Update("status", input.Status).Error
|
||||
return tx.Model(&membership).Updates(map[string]interface{}{"role_id": role.ID, "status": input.Status}).Error
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "UPDATE_FAILED", "更新成员失败")
|
||||
|
||||
@@ -20,9 +20,11 @@ type DetailHeader struct {
|
||||
|
||||
type DetailEvent struct {
|
||||
model.SOPRunEvent
|
||||
NodeTitle string `json:"node_title"`
|
||||
NodeType string `json:"node_type"`
|
||||
NodeContent string `json:"node_content"`
|
||||
NodeTitle string `json:"node_title"`
|
||||
NodeType string `json:"node_type"`
|
||||
NodeContent string `json:"node_content"`
|
||||
NodeConfig datatypes.JSON `json:"-"`
|
||||
Knowledge *KnowledgeView `json:"knowledge,omitempty" gorm:"-"`
|
||||
}
|
||||
|
||||
type DetailFeedback struct {
|
||||
@@ -45,10 +47,28 @@ func (h *Handler) Detail(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
events := make([]DetailEvent, 0)
|
||||
if err := h.db.Table("sop_run_events e").Select("e.*, n.title AS node_title, n.type AS node_type, n.content AS node_content").Joins("LEFT JOIN sop_nodes n ON n.sop_version_id = ? AND n.node_key = e.node_key", header.SOPVersionID).Where("e.run_id = ? AND e.tenant_id = ?", id, principal.TenantID).Order("e.created_at, e.id").Scan(&events).Error; err != nil {
|
||||
if err := h.db.Table("sop_run_events e").Select("e.*, n.title AS node_title, n.type AS node_type, n.content AS node_content, n.config AS node_config").Joins("LEFT JOIN sop_nodes n ON n.sop_version_id = ? AND n.node_key = e.node_key", header.SOPVersionID).Where("e.run_id = ? AND e.tenant_id = ?", id, principal.TenantID).Order("e.created_at, e.id").Scan(&events).Error; err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行事件失败")
|
||||
return
|
||||
}
|
||||
knowledgeCache := map[string]*KnowledgeView{}
|
||||
for i := range events {
|
||||
if events[i].NodeType != "knowledge" {
|
||||
continue
|
||||
}
|
||||
cacheKey := string(events[i].NodeConfig)
|
||||
if cached, ok := knowledgeCache[cacheKey]; ok {
|
||||
events[i].Knowledge = cached
|
||||
continue
|
||||
}
|
||||
knowledge, err := loadKnowledgeView(h.db, model.SOPNode{Type: events[i].NodeType, Config: events[i].NodeConfig}, principal.TenantID)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_NOT_FOUND", "执行记录关联的知识卡版本不存在")
|
||||
return
|
||||
}
|
||||
events[i].Knowledge = &knowledge
|
||||
knowledgeCache[cacheKey] = &knowledge
|
||||
}
|
||||
feedback := make([]DetailFeedback, 0)
|
||||
if err := h.db.Table("sop_feedback f").Select("f.*, u.display_name AS user_name").Joins("JOIN users u ON u.id = f.user_id").Where("f.run_id = ? AND f.tenant_id = ?", id, principal.TenantID).Order("f.created_at").Scan(&feedback).Error; err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行反馈失败")
|
||||
|
||||
@@ -11,30 +11,43 @@ func matchCondition(raw json.RawMessage, answers map[string]interface{}) (bool,
|
||||
if len(raw) == 0 || string(raw) == "{}" || string(raw) == "null" {
|
||||
return true, nil
|
||||
}
|
||||
var rule map[string]interface{}
|
||||
var rule interface{}
|
||||
if err := json.Unmarshal(raw, &rule); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if all, ok := rule["all"].([]interface{}); ok {
|
||||
for _, item := range all {
|
||||
object, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
return false, fmt.Errorf("invalid all condition")
|
||||
}
|
||||
matched, err := matchRule(object, answers)
|
||||
return matchConditionValue(rule, answers)
|
||||
}
|
||||
|
||||
func matchConditionValue(value interface{}, answers map[string]interface{}) (bool, error) {
|
||||
rule, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return false, fmt.Errorf("invalid condition")
|
||||
}
|
||||
all, hasAll := rule["all"]
|
||||
any, hasAny := rule["any"]
|
||||
if hasAll && hasAny {
|
||||
return false, fmt.Errorf("condition cannot contain both all and any")
|
||||
}
|
||||
if hasAll {
|
||||
items, ok := all.([]interface{})
|
||||
if !ok || len(items) == 0 {
|
||||
return false, fmt.Errorf("invalid all condition")
|
||||
}
|
||||
for _, item := range items {
|
||||
matched, err := matchConditionValue(item, answers)
|
||||
if err != nil || !matched {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
if any, ok := rule["any"].([]interface{}); ok {
|
||||
for _, item := range any {
|
||||
object, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
matched, err := matchRule(object, answers)
|
||||
if hasAny {
|
||||
items, ok := any.([]interface{})
|
||||
if !ok || len(items) == 0 {
|
||||
return false, fmt.Errorf("invalid any condition")
|
||||
}
|
||||
for _, item := range items {
|
||||
matched, err := matchConditionValue(item, answers)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ package run
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
func TestMatchCondition(t *testing.T) {
|
||||
@@ -17,6 +20,7 @@ func TestMatchCondition(t *testing.T) {
|
||||
{name: "greater", rule: `{"field":"weight","operator":"greater_than","value":5}`, want: true},
|
||||
{name: "contains", rule: `{"field":"symptom","operator":"contains","value":"呕吐"}`, want: true},
|
||||
{name: "all", rule: `{"all":[{"field":"urgent","operator":"equals","value":true},{"field":"weight","operator":"greater_than","value":5}]}`, want: true},
|
||||
{name: "nested groups", rule: `{"all":[{"field":"urgent","operator":"equals","value":true},{"any":[{"field":"weight","operator":"less_than","value":3},{"field":"symptom","operator":"contains","value":"呕吐"}]}]}`, want: true},
|
||||
{name: "any false", rule: `{"any":[{"field":"urgent","operator":"equals","value":false},{"field":"weight","operator":"less_than","value":3}]}`, want: false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
@@ -31,3 +35,22 @@ func TestMatchCondition(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchConditionRejectsAmbiguousGroup(t *testing.T) {
|
||||
_, err := matchCondition(json.RawMessage(`{"all":[{"field":"urgent","operator":"equals","value":true}],"any":[{"field":"urgent","operator":"equals","value":true}]}`), map[string]interface{}{"urgent": true})
|
||||
if err == nil {
|
||||
t.Fatal("matchCondition() should reject a condition containing both all and any")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortEdgesAlwaysPlacesDefaultLast(t *testing.T) {
|
||||
edges := []model.SOPEdge{
|
||||
{TargetNodeKey: "default", Condition: datatypes.JSON([]byte(`{}`)), Priority: 0},
|
||||
{TargetNodeKey: "second", Condition: datatypes.JSON([]byte(`{"field":"urgent","operator":"equals","value":false}`)), Priority: 20},
|
||||
{TargetNodeKey: "first", Condition: datatypes.JSON([]byte(`{"field":"urgent","operator":"equals","value":true}`)), Priority: 10},
|
||||
}
|
||||
sortEdges(edges)
|
||||
if edges[0].TargetNodeKey != "first" || edges[1].TargetNodeKey != "second" || edges[2].TargetNodeKey != "default" {
|
||||
t.Fatalf("sortEdges() order = %s, %s, %s", edges[0].TargetNodeKey, edges[1].TargetNodeKey, edges[2].TargetNodeKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,19 @@ type Handler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
type listItem struct {
|
||||
model.SOPRun
|
||||
SOPName string `json:"sop_name"`
|
||||
ScenarioName string `json:"scenario_name"`
|
||||
Version int `json:"version"`
|
||||
OperatorName string `json:"operator_name"`
|
||||
}
|
||||
|
||||
type filterOption struct {
|
||||
Value uint64 `json:"value"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
|
||||
func NewHandler(db *gorm.DB) *Handler {
|
||||
return &Handler{db: db}
|
||||
}
|
||||
@@ -101,12 +114,12 @@ func (h *Handler) Get(c *gin.Context) {
|
||||
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
p, _ := auth.PrincipalFromContext(c)
|
||||
type row struct {
|
||||
model.SOPRun
|
||||
SOPName string `json:"sop_name"`
|
||||
}
|
||||
page, pageSize := pagination(c)
|
||||
query := scopeRuns(h.db.Table("sop_runs r").Joins("JOIN sops s ON s.id = r.sop_id"), p, "r")
|
||||
query := scopeRuns(h.db.Table("sop_runs r").
|
||||
Joins("JOIN sops s ON s.id = r.sop_id").
|
||||
Joins("JOIN scenarios sc ON sc.id = s.scenario_id").
|
||||
Joins("JOIN sop_versions sv ON sv.id = r.sop_version_id").
|
||||
Joins("JOIN users u ON u.id = r.operator_id"), p, "r")
|
||||
if status := c.Query("status"); status != "" {
|
||||
query = query.Where("r.status = ?", status)
|
||||
}
|
||||
@@ -116,6 +129,9 @@ func (h *Handler) List(c *gin.Context) {
|
||||
if sopID := c.Query("sop_id"); sopID != "" {
|
||||
query = query.Where("r.sop_id = ?", sopID)
|
||||
}
|
||||
if operatorID := c.Query("operator_id"); operatorID != "" {
|
||||
query = query.Where("r.operator_id = ?", operatorID)
|
||||
}
|
||||
if startedFrom := c.Query("started_from"); startedFrom != "" {
|
||||
query = query.Where("r.started_at >= ?", startedFrom)
|
||||
}
|
||||
@@ -127,14 +143,37 @@ func (h *Handler) List(c *gin.Context) {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行记录失败")
|
||||
return
|
||||
}
|
||||
items := make([]row, 0)
|
||||
if err := query.Select("r.*, s.name AS sop_name").Order("r.created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&items).Error; err != nil {
|
||||
items := make([]listItem, 0)
|
||||
if err := query.Select("r.*, s.name AS sop_name, sc.name AS scenario_name, sv.version, u.display_name AS operator_name").Order("r.created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Scan(&items).Error; err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行记录失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items, "total": total, "page": page, "page_size": pageSize})
|
||||
}
|
||||
|
||||
func (h *Handler) Options(c *gin.Context) {
|
||||
p, _ := auth.PrincipalFromContext(c)
|
||||
sops := make([]filterOption, 0)
|
||||
sopQuery := scopeRuns(h.db.Table("sop_runs r"), p, "r").
|
||||
Select("DISTINCT s.id AS value, s.name AS label").
|
||||
Joins("JOIN sops s ON s.id = r.sop_id").
|
||||
Order("s.name, s.id")
|
||||
if err := sopQuery.Scan(&sops).Error; err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询 SOP 筛选项失败")
|
||||
return
|
||||
}
|
||||
operators := make([]filterOption, 0)
|
||||
operatorQuery := scopeRuns(h.db.Table("sop_runs r"), p, "r").
|
||||
Select("DISTINCT u.id AS value, u.display_name AS label").
|
||||
Joins("JOIN users u ON u.id = r.operator_id").
|
||||
Order("u.display_name, u.id")
|
||||
if err := operatorQuery.Scan(&operators).Error; err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询执行人筛选项失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"sops": sops, "operators": operators})
|
||||
}
|
||||
|
||||
func (h *Handler) Answer(c *gin.Context) {
|
||||
p, _ := auth.PrincipalFromContext(c)
|
||||
id, ok := runID(c)
|
||||
@@ -185,7 +224,7 @@ func (h *Handler) Answer(c *gin.Context) {
|
||||
if err := tx.Where("sop_version_id = ? AND source_node_key = ?", updated.SOPVersionID, updated.CurrentNodeKey).Order("priority, id").Find(&edges).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
sort.SliceStable(edges, func(i, j int) bool { return edges[i].Priority < edges[j].Priority })
|
||||
sortEdges(edges)
|
||||
nextKey := ""
|
||||
for _, edge := range edges {
|
||||
matched, matchErr := matchCondition(json.RawMessage(edge.Condition), answers)
|
||||
@@ -233,6 +272,29 @@ func (h *Handler) Answer(c *gin.Context) {
|
||||
h.respondRun(c, updated)
|
||||
}
|
||||
|
||||
func sortEdges(edges []model.SOPEdge) {
|
||||
sort.SliceStable(edges, func(i, j int) bool {
|
||||
leftDefault := defaultCondition(edges[i].Condition)
|
||||
rightDefault := defaultCondition(edges[j].Condition)
|
||||
if leftDefault != rightDefault {
|
||||
return !leftDefault
|
||||
}
|
||||
return edges[i].Priority < edges[j].Priority
|
||||
})
|
||||
}
|
||||
|
||||
func defaultCondition(raw []byte) bool {
|
||||
if len(raw) == 0 {
|
||||
return true
|
||||
}
|
||||
var value interface{}
|
||||
if err := json.Unmarshal(raw, &value); err != nil || value == nil {
|
||||
return err == nil && value == nil
|
||||
}
|
||||
object, ok := value.(map[string]interface{})
|
||||
return ok && len(object) == 0
|
||||
}
|
||||
|
||||
func (h *Handler) Finish(c *gin.Context) {
|
||||
p, _ := auth.PrincipalFromContext(c)
|
||||
id, ok := runID(c)
|
||||
@@ -240,29 +302,51 @@ func (h *Handler) Finish(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Result string `json:"result" binding:"required,max=64"`
|
||||
Result string `json:"result" binding:"required,oneof=manual"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "请选择执行结果")
|
||||
return
|
||||
}
|
||||
var run model.SOPRun
|
||||
if err := h.db.Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&run).Error; err != nil || !canOperateRun(p, run) {
|
||||
runCompletedErr := errors.New("执行记录已经结束")
|
||||
err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&run).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if !canOperateRun(p, run) {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
if run.Status != "running" {
|
||||
return runCompletedErr
|
||||
}
|
||||
now := time.Now()
|
||||
payload, _ := json.Marshal(input)
|
||||
if err := tx.Create(&model.SOPRunEvent{TenantID: p.TenantID, RunID: id, NodeKey: run.CurrentNodeKey, Action: "finish", Payload: datatypes.JSON(payload)}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&run).Updates(map[string]interface{}{"status": "completed", "result": input.Result, "completed_at": &now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
run.Status = "completed"
|
||||
run.Result = input.Result
|
||||
run.CompletedAt = &now
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
|
||||
return
|
||||
}
|
||||
if run.Status != "running" {
|
||||
response.Error(c, http.StatusConflict, "RUN_COMPLETED", "执行记录已经结束")
|
||||
if errors.Is(err, runCompletedErr) {
|
||||
response.Error(c, http.StatusConflict, "RUN_COMPLETED", err.Error())
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
result := h.db.Model(&model.SOPRun{}).Where("id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "running").Updates(map[string]interface{}{"status": "completed", "result": input.Result, "completed_at": &now})
|
||||
if result.Error != nil || result.RowsAffected == 0 {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "执行记录不存在")
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "FINISH_FAILED", "结束执行失败")
|
||||
return
|
||||
}
|
||||
_ = audit.Record(h.db, p, "finish", "sop_run", id, input)
|
||||
h.Get(c)
|
||||
h.respondRun(c, run)
|
||||
}
|
||||
|
||||
func (h *Handler) Feedback(c *gin.Context) {
|
||||
@@ -299,13 +383,21 @@ func (h *Handler) Feedback(c *gin.Context) {
|
||||
|
||||
func (h *Handler) respondRun(c *gin.Context, item model.SOPRun) {
|
||||
var node model.SOPNode
|
||||
if err := h.db.Where("sop_version_id = ? AND node_key = ?", item.SOPVersionID, item.CurrentNodeKey).First(&node).Error; err != nil {
|
||||
if err := h.db.Where("sop_version_id = ? AND node_key = ? AND tenant_id = ?", item.SOPVersionID, item.CurrentNodeKey, item.TenantID).First(&node).Error; err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "NODE_NOT_FOUND", "当前流程节点不存在")
|
||||
return
|
||||
}
|
||||
nodeView, err := h.nodeView(h.db, node, item.TenantID)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "KNOWLEDGE_NOT_FOUND", "当前节点关联的知识卡版本不存在")
|
||||
return
|
||||
}
|
||||
fields := make([]model.ScenarioField, 0)
|
||||
h.db.Table("scenario_fields sf").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ?", item.SOPID, item.TenantID).Order("sf.sort_order, sf.id").Find(&fields)
|
||||
response.OK(c, gin.H{"run": item, "node": node, "fields": fields})
|
||||
if err := h.db.Table("scenario_fields sf").Joins("JOIN sops s ON s.scenario_id = sf.scenario_id").Where("s.id = ? AND sf.tenant_id = ?", item.SOPID, item.TenantID).Order("sf.sort_order, sf.id").Find(&fields).Error; err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询场景字段失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"run": item, "node": nodeView, "fields": fields})
|
||||
}
|
||||
|
||||
func runID(c *gin.Context) (uint64, bool) {
|
||||
|
||||
76
internal/run/knowledge.go
Normal file
76
internal/run/knowledge.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package run
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type KnowledgeView struct {
|
||||
CardID uint64 `json:"card_id"`
|
||||
CardVersionID uint64 `json:"card_version_id"`
|
||||
Version int `json:"version"`
|
||||
Title string `json:"title"`
|
||||
StandardCopy string `json:"standard_copy"`
|
||||
ForbiddenCopy string `json:"forbidden_copy"`
|
||||
RiskNote string `json:"risk_note"`
|
||||
}
|
||||
|
||||
type NodeView struct {
|
||||
model.SOPNode
|
||||
Knowledge *KnowledgeView `json:"knowledge,omitempty"`
|
||||
}
|
||||
|
||||
type knowledgeNodeConfig struct {
|
||||
KnowledgeCardID uint64 `json:"knowledge_card_id"`
|
||||
KnowledgeCardVersionID uint64 `json:"knowledge_card_version_id"`
|
||||
}
|
||||
|
||||
func (h *Handler) nodeView(db *gorm.DB, node model.SOPNode, tenantID uint64) (NodeView, error) {
|
||||
view := NodeView{SOPNode: node}
|
||||
if node.Type != "knowledge" {
|
||||
return view, nil
|
||||
}
|
||||
knowledge, err := loadKnowledgeView(db, node, tenantID)
|
||||
if err != nil {
|
||||
return view, err
|
||||
}
|
||||
view.Knowledge = &knowledge
|
||||
return view, nil
|
||||
}
|
||||
|
||||
func loadKnowledgeView(db *gorm.DB, node model.SOPNode, tenantID uint64) (KnowledgeView, error) {
|
||||
var config knowledgeNodeConfig
|
||||
if err := json.Unmarshal(node.Config, &config); err != nil {
|
||||
return KnowledgeView{}, err
|
||||
}
|
||||
type row struct {
|
||||
model.KnowledgeCardVersion
|
||||
Title string `gorm:"column:title"`
|
||||
}
|
||||
var version row
|
||||
query := db.Table("knowledge_card_versions kv").Select("kv.*, kc.title").
|
||||
Joins("JOIN knowledge_cards kc ON kc.id = kv.knowledge_card_id").
|
||||
Where("kv.tenant_id = ? AND kc.tenant_id = ? AND kv.knowledge_card_id = ?", tenantID, tenantID, config.KnowledgeCardID)
|
||||
if config.KnowledgeCardVersionID != 0 {
|
||||
query = query.Where("kv.id = ?", config.KnowledgeCardVersionID)
|
||||
} else {
|
||||
query = query.Where("kv.status = ?", "published").Order("kv.version DESC")
|
||||
}
|
||||
if err := query.First(&version).Error; err != nil {
|
||||
return KnowledgeView{}, err
|
||||
}
|
||||
var content struct {
|
||||
StandardCopy string `json:"standard_copy"`
|
||||
ForbiddenCopy string `json:"forbidden_copy"`
|
||||
RiskNote string `json:"risk_note"`
|
||||
}
|
||||
if err := json.Unmarshal(version.Content, &content); err != nil {
|
||||
return KnowledgeView{}, err
|
||||
}
|
||||
return KnowledgeView{
|
||||
CardID: version.KnowledgeCardID, CardVersionID: version.ID, Version: version.Version, Title: version.Title,
|
||||
StandardCopy: content.StandardCopy, ForbiddenCopy: content.ForbiddenCopy, RiskNote: content.RiskNote,
|
||||
}, nil
|
||||
}
|
||||
@@ -13,6 +13,7 @@ type answerNodeConfig struct {
|
||||
FieldKey string `json:"field_key"`
|
||||
FieldKeys []string `json:"field_keys"`
|
||||
Required bool `json:"required"`
|
||||
Options []string `json:"options"`
|
||||
}
|
||||
|
||||
func validateNodeAnswers(node model.SOPNode, fields []model.ScenarioField, answers map[string]interface{}) error {
|
||||
@@ -68,10 +69,22 @@ func validateNodeAnswers(node model.SOPNode, fields []model.ScenarioField, answe
|
||||
if err := validateFieldValue(field, value); err != nil {
|
||||
return err
|
||||
}
|
||||
if node.Type == "choice" && !stringAllowed(config.Options, fmt.Sprint(value)) {
|
||||
return fmt.Errorf("%s的选项不正确", field.FieldName)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stringAllowed(options []string, selected string) bool {
|
||||
for _, option := range options {
|
||||
if option == selected {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validateFieldValue(field model.ScenarioField, value interface{}) error {
|
||||
switch field.FieldType {
|
||||
case "text", "textarea":
|
||||
|
||||
@@ -46,3 +46,15 @@ func TestValidateNodeAnswersRejectsAnswersForMessage(t *testing.T) {
|
||||
t.Fatalf("validateNodeAnswers() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNodeAnswersChoiceOptions(t *testing.T) {
|
||||
fields := []model.ScenarioField{{FieldKey: "intent", FieldName: "客户意向", FieldType: "text"}}
|
||||
node := model.SOPNode{Type: "choice", Config: datatypes.JSON([]byte(`{"field_key":"intent","required":true,"options":["继续了解","暂不考虑"]}`))}
|
||||
if err := validateNodeAnswers(node, fields, map[string]interface{}{"intent": "继续了解"}); err != nil {
|
||||
t.Fatalf("validateNodeAnswers() error = %v", err)
|
||||
}
|
||||
err := validateNodeAnswers(node, fields, map[string]interface{}{"intent": "绕过配置的值"})
|
||||
if err == nil || !strings.Contains(err.Error(), "选项不正确") {
|
||||
t.Fatalf("validateNodeAnswers() error = %v, want invalid choice option", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,13 @@ func TestValidateFieldInput(t *testing.T) {
|
||||
{name: "options null", input: fieldInput{FieldKey: "pet_note", FieldType: "text", Options: json.RawMessage(`null`)}, wantErr: true},
|
||||
{name: "validation array", input: fieldInput{FieldKey: "pet_age", FieldType: "number", Validation: json.RawMessage(`[1]`)}, wantErr: true},
|
||||
{name: "validation null", input: fieldInput{FieldKey: "pet_age", FieldType: "number", Validation: json.RawMessage(`null`)}, wantErr: true},
|
||||
{name: "number range", input: fieldInput{FieldKey: "pet_age", FieldType: "number", Validation: json.RawMessage(`{"min":0,"max":50}`)}},
|
||||
{name: "reversed number range", input: fieldInput{FieldKey: "pet_age", FieldType: "number", Validation: json.RawMessage(`{"min":50,"max":0}`)}, wantErr: true},
|
||||
{name: "number rule on text", input: fieldInput{FieldKey: "pet_name", FieldType: "text", Validation: json.RawMessage(`{"min":1}`)}, wantErr: true},
|
||||
{name: "text range", input: fieldInput{FieldKey: "pet_name", FieldType: "text", Validation: json.RawMessage(`{"min_length":1,"max_length":50}`)}},
|
||||
{name: "fractional text length", input: fieldInput{FieldKey: "pet_name", FieldType: "text", Validation: json.RawMessage(`{"min_length":1.5}`)}, wantErr: true},
|
||||
{name: "negative text length", input: fieldInput{FieldKey: "pet_name", FieldType: "text", Validation: json.RawMessage(`{"min_length":-1}`)}, wantErr: true},
|
||||
{name: "duplicate options", input: fieldInput{FieldKey: "pet_type", FieldType: "select", Options: json.RawMessage(`["猫","猫"]`)}, wantErr: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
|
||||
@@ -278,10 +278,16 @@ func validateFieldInput(input fieldInput) error {
|
||||
if err := json.Unmarshal(input.Options, &options); err != nil || options == nil {
|
||||
return errors.New("字段选项必须是文本数组")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, option := range options {
|
||||
if strings.TrimSpace(option) == "" {
|
||||
return errors.New("字段选项不能为空")
|
||||
}
|
||||
normalized := strings.TrimSpace(option)
|
||||
if seen[normalized] {
|
||||
return errors.New("字段选项不能重复")
|
||||
}
|
||||
seen[normalized] = true
|
||||
}
|
||||
if (input.FieldType == "select" || input.FieldType == "multiselect") && len(options) == 0 {
|
||||
return errors.New("单选或多选字段至少需要一个选项")
|
||||
@@ -290,14 +296,77 @@ func validateFieldInput(input fieldInput) error {
|
||||
return errors.New("单选或多选字段至少需要一个选项")
|
||||
}
|
||||
if len(input.Validation) > 0 {
|
||||
var validation map[string]interface{}
|
||||
var validation map[string]json.RawMessage
|
||||
if err := json.Unmarshal(input.Validation, &validation); err != nil || validation == nil {
|
||||
return errors.New("字段校验规则必须是 JSON 对象")
|
||||
}
|
||||
if err := validateFieldRules(input.FieldType, validation); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateFieldRules(fieldType string, validation map[string]json.RawMessage) error {
|
||||
allowed := map[string]bool{}
|
||||
switch fieldType {
|
||||
case "number":
|
||||
allowed["min"], allowed["max"] = true, true
|
||||
case "text", "textarea":
|
||||
allowed["min_length"], allowed["max_length"] = true, true
|
||||
}
|
||||
for key := range validation {
|
||||
if !allowed[key] {
|
||||
return errors.New("当前字段类型不支持校验规则 " + key)
|
||||
}
|
||||
}
|
||||
if fieldType == "number" {
|
||||
min, hasMin, err := jsonNumber(validation["min"])
|
||||
if err != nil {
|
||||
return errors.New("最小值必须是数字")
|
||||
}
|
||||
max, hasMax, err := jsonNumber(validation["max"])
|
||||
if err != nil {
|
||||
return errors.New("最大值必须是数字")
|
||||
}
|
||||
if hasMin && hasMax && min > max {
|
||||
return errors.New("最小值不能大于最大值")
|
||||
}
|
||||
}
|
||||
if fieldType == "text" || fieldType == "textarea" {
|
||||
min, hasMin, err := jsonInteger(validation["min_length"])
|
||||
if err != nil || (hasMin && min < 0) {
|
||||
return errors.New("最少字符必须是非负整数")
|
||||
}
|
||||
max, hasMax, err := jsonInteger(validation["max_length"])
|
||||
if err != nil || (hasMax && max < 1) {
|
||||
return errors.New("最多字符必须是正整数")
|
||||
}
|
||||
if hasMin && hasMax && min > max {
|
||||
return errors.New("最少字符不能大于最多字符")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func jsonNumber(raw json.RawMessage) (float64, bool, error) {
|
||||
if len(raw) == 0 {
|
||||
return 0, false, nil
|
||||
}
|
||||
var value float64
|
||||
err := json.Unmarshal(raw, &value)
|
||||
return value, true, err
|
||||
}
|
||||
|
||||
func jsonInteger(raw json.RawMessage) (int, bool, error) {
|
||||
if len(raw) == 0 {
|
||||
return 0, false, nil
|
||||
}
|
||||
var value int
|
||||
err := json.Unmarshal(raw, &value)
|
||||
return value, true, err
|
||||
}
|
||||
|
||||
func idParam(c *gin.Context, key string) (uint64, bool) {
|
||||
id, err := strconv.ParseUint(c.Param(key), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
|
||||
@@ -355,6 +355,9 @@ func (h *Handler) Publish(c *gin.Context) {
|
||||
}
|
||||
now := time.Now()
|
||||
err = h.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := BindKnowledgeVersions(tx, p.TenantID, version.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "published").Update("status", "superseded").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
58
internal/sop/knowledge_snapshot.go
Normal file
58
internal/sop/knowledge_snapshot.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package sop
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type knowledgeNodeConfig struct {
|
||||
KnowledgeCardID uint64 `json:"knowledge_card_id"`
|
||||
KnowledgeCardVersionID uint64 `json:"knowledge_card_version_id"`
|
||||
}
|
||||
|
||||
// BindKnowledgeVersions freezes the current published knowledge-card version
|
||||
// into every knowledge node before the SOP version becomes immutable.
|
||||
func BindKnowledgeVersions(tx *gorm.DB, tenantID, sopVersionID uint64) error {
|
||||
nodes := make([]model.SOPNode, 0)
|
||||
if err := tx.Where("tenant_id = ? AND sop_version_id = ? AND type = ?", tenantID, sopVersionID, "knowledge").Find(&nodes).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, node := range nodes {
|
||||
var config knowledgeNodeConfig
|
||||
if err := json.Unmarshal(node.Config, &config); err != nil || config.KnowledgeCardID == 0 {
|
||||
return fmt.Errorf("knowledge node %s has invalid configuration", node.NodeKey)
|
||||
}
|
||||
var version model.KnowledgeCardVersion
|
||||
err := tx.Table("knowledge_card_versions kv").Select("kv.*").
|
||||
Joins("JOIN knowledge_cards kc ON kc.id = kv.knowledge_card_id").
|
||||
Where("kv.tenant_id = ? AND kv.knowledge_card_id = ? AND kv.status = ? AND kc.tenant_id = ? AND kc.status = ?", tenantID, config.KnowledgeCardID, "published", tenantID, "published").
|
||||
Order("kv.version DESC").First(&version).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("knowledge node %s has no published card version: %w", node.NodeKey, err)
|
||||
}
|
||||
updated, err := withKnowledgeVersion(node.Config, version.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update knowledge node %s: %w", node.NodeKey, err)
|
||||
}
|
||||
if err := tx.Model(&model.SOPNode{}).Where("id = ? AND tenant_id = ? AND sop_version_id = ?", node.ID, tenantID, sopVersionID).Update("config", updated).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func withKnowledgeVersion(config datatypes.JSON, versionID uint64) (datatypes.JSON, error) {
|
||||
value := map[string]interface{}{}
|
||||
if len(config) > 0 {
|
||||
if err := json.Unmarshal(config, &value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
value["knowledge_card_version_id"] = versionID
|
||||
encoded, err := json.Marshal(value)
|
||||
return datatypes.JSON(encoded), err
|
||||
}
|
||||
22
internal/sop/knowledge_snapshot_test.go
Normal file
22
internal/sop/knowledge_snapshot_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package sop
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
func TestWithKnowledgeVersionPreservesConfiguration(t *testing.T) {
|
||||
updated, err := withKnowledgeVersion(datatypes.JSON([]byte(`{"knowledge_card_id":12,"display":"full"}`)), 34)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var value map[string]interface{}
|
||||
if err := json.Unmarshal(updated, &value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value["knowledge_card_id"] != float64(12) || value["knowledge_card_version_id"] != float64(34) || value["display"] != "full" {
|
||||
t.Fatalf("unexpected knowledge config: %#v", value)
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,8 @@ func ValidateGraph(startNodeKey string, nodes []model.SOPNode, edges []model.SOP
|
||||
}
|
||||
if _, exists := nodeMap[startNodeKey]; !exists && startNodeKey != "" {
|
||||
problems = append(problems, "开始节点不存在")
|
||||
} else if startNodeKey != "" && nodeMap[startNodeKey].Type != "start" {
|
||||
problems = append(problems, "开始节点标识必须指向 start 类型节点")
|
||||
}
|
||||
|
||||
adjacency := make(map[string][]string)
|
||||
@@ -77,6 +79,9 @@ func ValidateGraph(startNodeKey string, nodes []model.SOPNode, edges []model.SOP
|
||||
if node.Type != "finish" && node.Type != "escalate" && outgoing[node.NodeKey] == 0 {
|
||||
problems = append(problems, fmt.Sprintf("节点“%s”没有下一步", node.Title))
|
||||
}
|
||||
if (node.Type == "finish" || node.Type == "escalate") && outgoing[node.NodeKey] > 0 {
|
||||
problems = append(problems, fmt.Sprintf("结束节点“%s”不能配置下一步", node.Title))
|
||||
}
|
||||
}
|
||||
|
||||
visited := map[string]bool{}
|
||||
@@ -147,6 +152,9 @@ func ValidateForPublish(startNodeKey string, nodes []model.SOPNode, edges []mode
|
||||
problems = append(problems, fmt.Sprintf("节点“%s”引用的字段 %s 不存在", node.Title, fieldKey))
|
||||
} else {
|
||||
collected[fieldKey] = true
|
||||
if node.Type == "choice" {
|
||||
validateChoiceOptions(node, config, fieldMap[fieldKey], &problems)
|
||||
}
|
||||
}
|
||||
case "form":
|
||||
keys, _ := config["field_keys"].([]interface{})
|
||||
@@ -218,12 +226,57 @@ func ValidateForPublish(startNodeKey string, nodes []model.SOPNode, edges []mode
|
||||
return problems
|
||||
}
|
||||
|
||||
func validateChoiceOptions(node model.SOPNode, config map[string]interface{}, field model.ScenarioField, problems *[]string) {
|
||||
values, ok := config["options"].([]interface{})
|
||||
if !ok || len(values) == 0 {
|
||||
*problems = append(*problems, fmt.Sprintf("选择节点“%s”没有配置选项", node.Title))
|
||||
return
|
||||
}
|
||||
if field.FieldType != "text" && field.FieldType != "select" {
|
||||
*problems = append(*problems, fmt.Sprintf("选择节点“%s”只能写入文本或单选字段", node.Title))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
option, ok := value.(string)
|
||||
if !ok || option == "" {
|
||||
*problems = append(*problems, fmt.Sprintf("选择节点“%s”包含无效选项", node.Title))
|
||||
continue
|
||||
}
|
||||
if seen[option] {
|
||||
*problems = append(*problems, fmt.Sprintf("选择节点“%s”包含重复选项", node.Title))
|
||||
}
|
||||
seen[option] = true
|
||||
if field.FieldType == "select" && !jsonStringArrayContains(field.Options, option) {
|
||||
*problems = append(*problems, fmt.Sprintf("选择节点“%s”的选项“%s”不在字段选项中", node.Title, option))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func jsonStringArrayContains(raw []byte, expected string) bool {
|
||||
var values []string
|
||||
if json.Unmarshal(raw, &values) != nil {
|
||||
return false
|
||||
}
|
||||
for _, value := range values {
|
||||
if value == expected {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validateCondition(value interface{}, fields map[string]model.ScenarioField, label string, problems *[]string) {
|
||||
rule, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
*problems = append(*problems, label+"的条件结构不正确")
|
||||
return
|
||||
}
|
||||
_, hasAll := rule["all"]
|
||||
_, hasAny := rule["any"]
|
||||
if hasAll && hasAny {
|
||||
*problems = append(*problems, label+"不能同时包含全部满足和任一满足")
|
||||
return
|
||||
}
|
||||
for _, group := range []string{"all", "any"} {
|
||||
if raw, exists := rule[group]; exists {
|
||||
items, ok := raw.([]interface{})
|
||||
|
||||
@@ -42,6 +42,12 @@ func TestValidateForPublishBusinessRules(t *testing.T) {
|
||||
{name: "high risk without escalation", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
|
||||
(*edges)[1].TargetNodeKey = "finish"
|
||||
}, want: "没有明确的转人工或转诊路径"},
|
||||
{name: "terminal node has outgoing edge", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
|
||||
*edges = append(*edges, model.SOPEdge{SourceNodeKey: "finish", TargetNodeKey: "screen", Condition: jsonData(`{}`)})
|
||||
}, want: "不能配置下一步"},
|
||||
{name: "ambiguous condition group", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
|
||||
(*edges)[1].Condition = jsonData(`{"all":[{"field":"emergency","operator":"equals","value":true}],"any":[{"field":"emergency","operator":"equals","value":true}]}`)
|
||||
}, want: "不能同时包含全部满足和任一满足"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
@@ -55,6 +61,29 @@ func TestValidateForPublishBusinessRules(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGraphRequiresConfiguredStartType(t *testing.T) {
|
||||
nodes, edges, _ := validPublishGraph()
|
||||
if problems := ValidateGraph("screen", nodes, edges); !containsProblem(problems, "必须指向 start 类型节点") {
|
||||
t.Fatalf("ValidateGraph() problems = %v", problems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateForPublishChoiceConfiguration(t *testing.T) {
|
||||
nodes := []model.SOPNode{
|
||||
{NodeKey: "start", Type: "start", Title: "开始", Config: jsonData(`{}`)},
|
||||
{NodeKey: "choice", Type: "choice", Title: "客户意向", Config: jsonData(`{"field_key":"intent","options":["继续","无效"]}`)},
|
||||
{NodeKey: "finish", Type: "finish", Title: "结束", Config: jsonData(`{}`)},
|
||||
}
|
||||
edges := []model.SOPEdge{
|
||||
{SourceNodeKey: "start", TargetNodeKey: "choice", Condition: jsonData(`{}`)},
|
||||
{SourceNodeKey: "choice", TargetNodeKey: "finish", Condition: jsonData(`{}`)},
|
||||
}
|
||||
context := ValidationContext{Fields: []model.ScenarioField{{FieldKey: "intent", FieldName: "客户意向", FieldType: "select", Options: jsonData(`["继续","暂不考虑"]`)}}}
|
||||
if problems := ValidateForPublish("start", nodes, edges, context); !containsProblem(problems, "不在字段选项中") {
|
||||
t.Fatalf("ValidateForPublish() problems = %v", problems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGraphRejectsNonTerminatingCycle(t *testing.T) {
|
||||
nodes := []model.SOPNode{
|
||||
{NodeKey: "start", Type: "start", Title: "开始", Config: jsonData(`{}`)},
|
||||
|
||||
2
main.go
2
main.go
@@ -42,6 +42,8 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer log.Sync()
|
||||
undoGlobalLogger := zap.ReplaceGlobals(log.Named("business"))
|
||||
defer undoGlobalLogger()
|
||||
|
||||
db, err := database.Open(cfg.Database, log)
|
||||
if err != nil {
|
||||
|
||||
4
migrations/000002_bind_knowledge_versions.down.sql
Normal file
4
migrations/000002_bind_knowledge_versions.down.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
UPDATE sop_nodes
|
||||
SET config = JSON_REMOVE(config, '$.knowledge_card_version_id')
|
||||
WHERE type = 'knowledge'
|
||||
AND JSON_EXTRACT(config, '$.knowledge_card_version_id') IS NOT NULL;
|
||||
25
migrations/000002_bind_knowledge_versions.up.sql
Normal file
25
migrations/000002_bind_knowledge_versions.up.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
UPDATE sop_nodes n
|
||||
JOIN sop_versions sv ON sv.id = n.sop_version_id
|
||||
SET n.config = JSON_SET(
|
||||
n.config,
|
||||
'$.knowledge_card_version_id',
|
||||
(
|
||||
SELECT kv.id
|
||||
FROM knowledge_card_versions kv
|
||||
WHERE kv.tenant_id = n.tenant_id
|
||||
AND kv.knowledge_card_id = CAST(JSON_UNQUOTE(JSON_EXTRACT(n.config, '$.knowledge_card_id')) AS UNSIGNED)
|
||||
AND kv.created_at <= COALESCE(sv.published_at, sv.updated_at)
|
||||
ORDER BY kv.version DESC
|
||||
LIMIT 1
|
||||
)
|
||||
)
|
||||
WHERE n.type = 'knowledge'
|
||||
AND JSON_EXTRACT(n.config, '$.knowledge_card_version_id') IS NULL
|
||||
AND JSON_EXTRACT(n.config, '$.knowledge_card_id') IS NOT NULL
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM knowledge_card_versions kv
|
||||
WHERE kv.tenant_id = n.tenant_id
|
||||
AND kv.knowledge_card_id = CAST(JSON_UNQUOTE(JSON_EXTRACT(n.config, '$.knowledge_card_id')) AS UNSIGNED)
|
||||
AND kv.created_at <= COALESCE(sv.published_at, sv.updated_at)
|
||||
);
|
||||
@@ -5,12 +5,14 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/config"
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/database"
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/logger"
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||
sopservice "git.iwork-ai.com/xdc/iqudo-top1/internal/sop"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
@@ -124,29 +126,7 @@ func seedScenario(tx *gorm.DB, tenantID, userID uint64) (model.Scenario, error)
|
||||
}
|
||||
|
||||
func seedFields(tx *gorm.DB, tenantID, scenarioID uint64) error {
|
||||
definitions := []fieldDefinition{
|
||||
{Key: "pet_name", Name: "宠物名称", Type: "text", Required: true},
|
||||
{Key: "pet_type", Name: "宠物种类", Type: "select", Required: true, Options: []string{"犬", "猫", "其他"}},
|
||||
{Key: "pet_age", Name: "年龄(岁)", Type: "number", Validation: map[string]interface{}{"min": 0, "max": 50}},
|
||||
{Key: "pet_weight", Name: "体重(kg)", Type: "number", Validation: map[string]interface{}{"min": 0.01, "max": 200}},
|
||||
{Key: "pet_sex", Name: "性别", Type: "select", Options: []string{"公", "母", "未知"}},
|
||||
{Key: "is_neutered", Name: "是否绝育", Type: "boolean"},
|
||||
{Key: "symptom", Name: "主要症状", Type: "textarea", Required: true},
|
||||
{Key: "symptom_duration", Name: "症状持续时间", Type: "text", Required: true},
|
||||
{Key: "breathing_difficulty", Name: "是否呼吸困难", Type: "boolean", Required: true},
|
||||
{Key: "active_bleeding", Name: "是否持续出血", Type: "boolean", Required: true},
|
||||
{Key: "convulsion", Name: "是否抽搐或意识异常", Type: "boolean", Required: true},
|
||||
{Key: "unable_to_urinate", Name: "是否无法排尿", Type: "boolean", Required: true},
|
||||
{Key: "toxin_exposure", Name: "是否可能误食毒物或异物", Type: "boolean", Required: true},
|
||||
{Key: "has_emergency_sign", Name: "是否存在其他急症表现", Type: "boolean", Required: true},
|
||||
{Key: "appetite", Name: "食欲情况", Type: "select", Options: []string{"正常", "下降", "完全不吃"}},
|
||||
{Key: "spirit_status", Name: "精神状态", Type: "select", Options: []string{"正常", "较差", "嗜睡或无法站立"}},
|
||||
{Key: "vomiting", Name: "是否呕吐", Type: "boolean"},
|
||||
{Key: "diarrhea", Name: "是否腹泻", Type: "boolean"},
|
||||
{Key: "medication_history", Name: "近期用药和保健品", Type: "textarea"},
|
||||
{Key: "allergy_history", Name: "药物过敏史", Type: "textarea"},
|
||||
{Key: "wants_medication", Name: "是否咨询具体用药", Type: "boolean", Required: true},
|
||||
}
|
||||
definitions := petFieldDefinitions()
|
||||
for index, definition := range definitions {
|
||||
options, _ := json.Marshal(definition.Options)
|
||||
if definition.Options == nil {
|
||||
@@ -168,10 +148,67 @@ func seedFields(tx *gorm.DB, tenantID, scenarioID uint64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func petFieldDefinitions() []fieldDefinition {
|
||||
return []fieldDefinition{
|
||||
{Key: "consultation_channel", Name: "咨询渠道", Type: "select", Required: true, Options: []string{"到店", "电话", "在线图文", "视频"}},
|
||||
{Key: "visit_goal", Name: "本次咨询目的", Type: "select", Required: true, Options: []string{"症状咨询", "用药咨询", "复诊随访", "检查报告解读", "其他"}},
|
||||
{Key: "pet_name", Name: "宠物名称", Type: "text", Required: true, Validation: map[string]interface{}{"max_length": 50}},
|
||||
{Key: "pet_type", Name: "宠物种类", Type: "select", Required: true, Options: []string{"犬", "猫", "其他"}},
|
||||
{Key: "pet_breed", Name: "品种", Type: "text", Validation: map[string]interface{}{"max_length": 50}},
|
||||
{Key: "pet_age", Name: "年龄(岁)", Type: "number", Validation: map[string]interface{}{"min": 0, "max": 50}},
|
||||
{Key: "pet_weight", Name: "体重(kg)", Type: "number", Validation: map[string]interface{}{"min": 0.01, "max": 200}},
|
||||
{Key: "pet_sex", Name: "性别", Type: "select", Options: []string{"公", "母", "未知"}},
|
||||
{Key: "is_neutered", Name: "是否绝育", Type: "boolean"},
|
||||
{Key: "pregnancy_status", Name: "妊娠或哺乳情况", Type: "select", Options: []string{"不适用", "否", "可能妊娠", "妊娠中", "哺乳中", "不清楚"}},
|
||||
{Key: "symptom", Name: "主要症状", Type: "textarea", Required: true, Validation: map[string]interface{}{"max_length": 1000}},
|
||||
{Key: "symptom_duration", Name: "症状开始时间及持续时长", Type: "text", Required: true, Validation: map[string]interface{}{"max_length": 100}},
|
||||
{Key: "symptom_trend", Name: "症状变化趋势", Type: "select", Required: true, Options: []string{"好转", "稳定", "反复", "加重", "不清楚"}},
|
||||
{Key: "prior_visit", Name: "本次问题是否已就诊", Type: "boolean"},
|
||||
{Key: "breathing_difficulty", Name: "是否呼吸困难", Type: "boolean", Required: true},
|
||||
{Key: "active_bleeding", Name: "是否持续出血", Type: "boolean", Required: true},
|
||||
{Key: "convulsion", Name: "是否抽搐或意识异常", Type: "boolean", Required: true},
|
||||
{Key: "severe_trauma", Name: "是否遭遇严重外伤或高处坠落", Type: "boolean", Required: true},
|
||||
{Key: "unable_to_urinate", Name: "是否无法排尿", Type: "boolean", Required: true},
|
||||
{Key: "toxin_exposure", Name: "是否可能误食毒物或异物", Type: "boolean", Required: true},
|
||||
{Key: "repeated_vomiting", Name: "是否持续呕吐或干呕", Type: "boolean", Required: true},
|
||||
{Key: "severe_pain", Name: "是否剧烈疼痛或腹部明显膨大", Type: "boolean", Required: true},
|
||||
{Key: "heatstroke_sign", Name: "是否疑似中暑或体温异常升高", Type: "boolean", Required: true},
|
||||
{Key: "unable_to_stand", Name: "是否虚弱到无法站立", Type: "boolean", Required: true},
|
||||
{Key: "has_emergency_sign", Name: "是否存在其他危及生命的表现", Type: "boolean", Required: true},
|
||||
{Key: "appetite", Name: "食欲情况", Type: "select", Options: []string{"正常", "下降", "完全不吃"}},
|
||||
{Key: "water_intake", Name: "饮水情况", Type: "select", Options: []string{"正常", "增多", "减少", "完全不喝", "不清楚"}},
|
||||
{Key: "spirit_status", Name: "精神状态", Type: "select", Options: []string{"正常", "较差", "嗜睡或无法站立"}},
|
||||
{Key: "vomiting", Name: "是否呕吐", Type: "boolean"},
|
||||
{Key: "diarrhea", Name: "是否腹泻", Type: "boolean"},
|
||||
{Key: "urination_status", Name: "排尿情况", Type: "select", Options: []string{"正常", "增多", "减少", "频繁少量", "带血", "不清楚"}},
|
||||
{Key: "stool_status", Name: "排便情况", Type: "select", Options: []string{"正常", "稀软", "水样", "带血或黑便", "便秘", "不清楚"}},
|
||||
{Key: "measured_temperature", Name: "实测体温(℃)", Type: "number", Validation: map[string]interface{}{"min": 30, "max": 45}},
|
||||
{Key: "chronic_disease_history", Name: "既往疾病及手术史", Type: "textarea", Validation: map[string]interface{}{"max_length": 1000}},
|
||||
{Key: "vaccination_status", Name: "免疫情况", Type: "select", Options: []string{"按期完成", "未完成", "逾期", "不清楚", "不适用"}},
|
||||
{Key: "medication_history", Name: "近期用药、驱虫药和保健品", Type: "textarea", Validation: map[string]interface{}{"max_length": 1000}},
|
||||
{Key: "allergy_history", Name: "已知药物过敏或不良反应史", Type: "textarea", Validation: map[string]interface{}{"max_length": 500}},
|
||||
{Key: "wants_medication", Name: "是否咨询具体用药", Type: "boolean", Required: true},
|
||||
{Key: "medication_request_type", Name: "用药咨询类型", Type: "select", Options: []string{"新症状想用药", "询问现有处方用法", "续方或复购", "漏服或多服", "服药后异常", "其他"}},
|
||||
{Key: "requested_medication", Name: "咨询的药品名称及规格", Type: "text", Validation: map[string]interface{}{"max_length": 200}},
|
||||
{Key: "prescribed_by_vet", Name: "是否由宠物医生开具", Type: "boolean"},
|
||||
{Key: "has_taken_medication", Name: "是否已经使用该药", Type: "boolean"},
|
||||
{Key: "dose_and_time", Name: "已用剂量、次数及最后用药时间", Type: "textarea", Validation: map[string]interface{}{"max_length": 500}},
|
||||
{Key: "human_medication_exposure", Name: "是否使用或误食人用药", Type: "boolean", Required: true},
|
||||
{Key: "suspected_overdose", Name: "是否可能过量或重复用药", Type: "boolean", Required: true},
|
||||
{Key: "adverse_reaction", Name: "用药后是否出现异常反应", Type: "boolean", Required: true},
|
||||
{Key: "adverse_reaction_detail", Name: "异常反应描述", Type: "textarea", Validation: map[string]interface{}{"max_length": 1000}},
|
||||
}
|
||||
}
|
||||
|
||||
func seedKnowledgeCards(tx *gorm.DB, tenantID, userID, scenarioID uint64) (map[string]uint64, error) {
|
||||
definitions := map[string]map[string]interface{}{
|
||||
"线上问诊边界": {
|
||||
"standard_copy": "线上沟通用于收集信息和判断紧急程度,不能替代体格检查、检验和影像检查。医生需要结合完整病史及检查结果后才能给出诊断和治疗方案。",
|
||||
"forbidden_copy": "不要仅凭文字、照片或单个症状作出确定诊断,也不要承诺某种处理一定有效。",
|
||||
"risk_note": "信息不完整、症状持续加重或无法准确观察时,应建议尽快到院评估。",
|
||||
},
|
||||
"急症红旗征象": {
|
||||
"standard_copy": "出现呼吸困难、持续出血、抽搐或意识异常、无法排尿、疑似误食毒物或异物等情况时,应立即建议就近急诊或转诊,并提前联系接诊机构。",
|
||||
"standard_copy": "出现呼吸困难、持续出血、抽搐或意识异常、严重外伤、无法排尿、持续呕吐或干呕、剧烈疼痛、腹部明显膨大、疑似中暑、无法站立、误食毒物或异物等情况时,应立即建议就近急诊或转诊,并提前联系接诊机构。",
|
||||
"forbidden_copy": "不要承诺在家观察一定安全;不要在线给出能够替代急诊检查的判断。",
|
||||
"risk_note": "急症分支优先级最高,不得因继续询问常规病史而延误就医。",
|
||||
},
|
||||
@@ -180,6 +217,11 @@ func seedKnowledgeCards(tx *gorm.DB, tenantID, userID, scenarioID uint64) (map[s
|
||||
"forbidden_copy": "未完成医生评估前,不给出具体处方药名称、剂量和疗程承诺。",
|
||||
"risk_note": "对乙酰氨基酚、布洛芬等常见人用药可能对宠物造成严重伤害;如已误服,应按急症处理。",
|
||||
},
|
||||
"观察与复诊提示": {
|
||||
"standard_copy": "请按医生要求记录精神、食欲、饮水、排尿、排便、呕吐次数及症状变化。若症状加重、出现新的急症表现,或在医生建议的观察时间内未改善,应尽快复诊。",
|
||||
"forbidden_copy": "不要用固定天数替代医生根据病情给出的复诊时间,也不要因一次短暂好转自行停药。",
|
||||
"risk_note": "离开本次沟通前,应让宠物主人复述下一步安排和需要立即就医的触发条件。",
|
||||
},
|
||||
}
|
||||
result := make(map[string]uint64, len(definitions))
|
||||
for title, content := range definitions {
|
||||
@@ -194,11 +236,7 @@ func seedKnowledgeCards(tx *gorm.DB, tenantID, userID, scenarioID uint64) (map[s
|
||||
return nil, err
|
||||
}
|
||||
contentJSON, _ := json.Marshal(content)
|
||||
var version model.KnowledgeCardVersion
|
||||
err = tx.Where("knowledge_card_id = ? AND version = ?", card.ID, 1).Assign(model.KnowledgeCardVersion{
|
||||
TenantID: tenantID, Content: datatypes.JSON(contentJSON), Status: "published",
|
||||
}).FirstOrCreate(&version, model.KnowledgeCardVersion{KnowledgeCardID: card.ID, Version: 1}).Error
|
||||
if err != nil {
|
||||
if err := publishSeedKnowledgeVersion(tx, tenantID, card.ID, contentJSON); err != nil {
|
||||
return nil, fmt.Errorf("seed knowledge card version %s: %w", title, err)
|
||||
}
|
||||
result[title] = card.ID
|
||||
@@ -206,6 +244,42 @@ func seedKnowledgeCards(tx *gorm.DB, tenantID, userID, scenarioID uint64) (map[s
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func publishSeedKnowledgeVersion(tx *gorm.DB, tenantID, cardID uint64, contentJSON []byte) error {
|
||||
var latest model.KnowledgeCardVersion
|
||||
err := tx.Where("knowledge_card_id = ? AND tenant_id = ?", cardID, tenantID).Order("version DESC").First(&latest).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return tx.Create(&model.KnowledgeCardVersion{
|
||||
TenantID: tenantID, KnowledgeCardID: cardID, Version: 1,
|
||||
Content: datatypes.JSON(contentJSON), Status: "published",
|
||||
}).Error
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sameJSON(latest.Content, contentJSON) {
|
||||
if err := tx.Model(&model.KnowledgeCardVersion{}).Where("knowledge_card_id = ? AND tenant_id = ? AND id <> ? AND status = ?", cardID, tenantID, latest.ID, "published").Update("status", "superseded").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&latest).Update("status", "published").Error
|
||||
}
|
||||
if err := tx.Model(&model.KnowledgeCardVersion{}).Where("knowledge_card_id = ? AND tenant_id = ? AND status = ?", cardID, tenantID, "published").Update("status", "superseded").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&model.KnowledgeCardVersion{
|
||||
TenantID: tenantID, KnowledgeCardID: cardID, Version: latest.Version + 1,
|
||||
Content: datatypes.JSON(contentJSON), Status: "published",
|
||||
}).Error
|
||||
}
|
||||
|
||||
func sameJSON(left, right []byte) bool {
|
||||
var leftValue interface{}
|
||||
var rightValue interface{}
|
||||
if json.Unmarshal(left, &leftValue) != nil || json.Unmarshal(right, &rightValue) != nil {
|
||||
return false
|
||||
}
|
||||
return reflect.DeepEqual(leftValue, rightValue)
|
||||
}
|
||||
|
||||
func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64, knowledgeIDs map[string]uint64) error {
|
||||
var sop model.SOP
|
||||
err := tx.Where("tenant_id = ? AND scenario_id = ? AND name = ?", tenantID, scenarioID, "宠物问诊问药标准 SOP").FirstOrCreate(&sop, model.SOP{
|
||||
@@ -215,7 +289,7 @@ func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64, knowledgeIDs map[
|
||||
return fmt.Errorf("find or create SOP: %w", err)
|
||||
}
|
||||
if err := tx.Model(&sop).Updates(map[string]interface{}{
|
||||
"description": "从基本信息和主诉采集开始,优先筛查急症,再补充病史并给出安全的下一步指引。",
|
||||
"description": "标准化采集宠物主诉和病史,通过急症及用药风险双重分流,形成医生评估、转诊和复诊闭环。",
|
||||
"status": "published",
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
@@ -223,7 +297,7 @@ func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64, knowledgeIDs map[
|
||||
var seededPublished int64
|
||||
if err := tx.Table("sop_nodes n").Joins("JOIN sop_versions v ON v.id = n.sop_version_id").Where(
|
||||
"v.sop_id = ? AND n.node_key = ? AND JSON_UNQUOTE(JSON_EXTRACT(n.config, '$.seed_key')) = ?",
|
||||
sop.ID, "start", "pet-doctor-v2",
|
||||
sop.ID, "start", "pet-doctor-v5",
|
||||
).Count(&seededPublished).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -246,7 +320,7 @@ func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64, knowledgeIDs map[
|
||||
return err
|
||||
}
|
||||
|
||||
nodes := petNodes(knowledgeIDs["问诊用药安全原则"])
|
||||
nodes := petNodes(knowledgeIDs)
|
||||
edges := petEdges()
|
||||
if err := tx.Where("sop_version_id = ?", version.ID).Delete(&model.SOPEdge{}).Error; err != nil {
|
||||
return err
|
||||
@@ -274,6 +348,9 @@ func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64, knowledgeIDs map[
|
||||
return fmt.Errorf("create edge %s -> %s: %w", definition.Source, definition.Target, err)
|
||||
}
|
||||
}
|
||||
if err := sopservice.BindKnowledgeVersions(tx, tenantID, version.ID); err != nil {
|
||||
return fmt.Errorf("bind knowledge versions: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND id <> ? AND status = ?", sop.ID, version.ID, "published").Update("status", "superseded").Error; err != nil {
|
||||
@@ -290,20 +367,29 @@ func seedSOP(tx *gorm.DB, tenantID, userID, scenarioID uint64, knowledgeIDs map[
|
||||
}).Error
|
||||
}
|
||||
|
||||
func petNodes(knowledgeCardID uint64) []nodeDefinition {
|
||||
func petNodes(knowledgeIDs map[string]uint64) []nodeDefinition {
|
||||
return []nodeDefinition{
|
||||
{Key: "start", Type: "start", Title: "开始", Config: map[string]interface{}{"seed_key": "pet-doctor-v2"}},
|
||||
{Key: "opening", Type: "message", Title: "说明问诊流程", Content: "您好,我会先记录宠物的基本信息和症状,并优先确认是否存在需要立即就医的情况。线上沟通不能替代医生检查。", Config: map[string]interface{}{}},
|
||||
{Key: "basic_info", Type: "form", Title: "采集宠物基本信息", Content: "请确认宠物名称、种类、年龄、体重、性别和绝育情况。", Config: map[string]interface{}{"field_keys": []string{"pet_name", "pet_type", "pet_age", "pet_weight", "pet_sex", "is_neutered"}}},
|
||||
{Key: "chief_complaint", Type: "form", Title: "记录主诉", Content: "请让宠物主人按时间顺序描述最主要的症状和持续时间。", Config: map[string]interface{}{"field_keys": []string{"symptom", "symptom_duration"}}},
|
||||
{Key: "emergency_screen", Type: "form", Title: "筛查急症红旗", Content: "逐项确认当前是否存在以下急症表现。任意一项为“是”都应优先转诊。", Config: map[string]interface{}{"field_keys": []string{"breathing_difficulty", "active_bleeding", "convulsion", "unable_to_urinate", "toxin_exposure", "has_emergency_sign"}}},
|
||||
{Key: "start", Type: "start", Title: "开始", Config: map[string]interface{}{"seed_key": "pet-doctor-v5"}},
|
||||
{Key: "opening", Type: "message", Title: "说明问诊流程与边界", Content: "您好,我会先记录本次咨询目的、宠物基本信息和症状,优先排查是否需要立即就医,再将完整信息提交给宠物医生评估。线上沟通不能替代医生检查。", Config: map[string]interface{}{}},
|
||||
{Key: "consultation_context", Type: "form", Title: "确认咨询背景", Content: "请确认本次咨询渠道和主要目的,便于医生判断沟通重点。", Config: map[string]interface{}{"field_keys": []string{"consultation_channel", "visit_goal"}}},
|
||||
{Key: "basic_info", Type: "form", Title: "采集宠物基本信息", Content: "请确认宠物名称、种类、品种、年龄、体重、性别、绝育以及妊娠或哺乳情况;不确定的信息可以留空。", Config: map[string]interface{}{"field_keys": []string{"pet_name", "pet_type", "pet_breed", "pet_age", "pet_weight", "pet_sex", "is_neutered", "pregnancy_status"}}},
|
||||
{Key: "chief_complaint", Type: "form", Title: "记录主诉与变化", Content: "请让宠物主人按时间顺序描述主要症状、开始时间、变化趋势,以及是否已经就诊。", Config: map[string]interface{}{"field_keys": []string{"symptom", "symptom_duration", "symptom_trend", "prior_visit"}}},
|
||||
{Key: "emergency_screen", Type: "form", Title: "筛查急症红旗", Content: "请逐项询问,不要仅凭主人主动描述判断。任意一项为“是”都应停止常规问诊并优先转诊。", Config: map[string]interface{}{"field_keys": []string{"breathing_difficulty", "active_bleeding", "convulsion", "severe_trauma", "unable_to_urinate", "toxin_exposure", "repeated_vomiting", "severe_pain", "heatstroke_sign", "unable_to_stand", "has_emergency_sign"}}},
|
||||
{Key: "emergency_check", Type: "condition", Title: "判断是否需要立即转诊", Content: "系统根据急症筛查结果自动分流。", Config: map[string]interface{}{"risk_level": "high"}},
|
||||
{Key: "emergency_escalate", Type: "escalate", Title: "立即急诊或转诊", Content: "存在急症红旗,请停止常规线上问诊,建议立即前往最近的宠物急诊,并提前联系接诊机构。不要自行喂药、催吐或强行喂食。", Config: map[string]interface{}{"action": "urgent_referral"}},
|
||||
{Key: "history_collection", Type: "form", Title: "补充状态与病史", Content: "继续了解食欲、精神状态、消化道表现、近期用药和药物过敏史。", Config: map[string]interface{}{"field_keys": []string{"appetite", "spirit_status", "vomiting", "diarrhea", "medication_history", "allergy_history"}}},
|
||||
{Key: "medication_intent", Type: "question", Title: "确认用药诉求", Content: "请确认宠物主人是否正在咨询具体药物、剂量或疗程。", Config: map[string]interface{}{"field_key": "wants_medication", "required": true}},
|
||||
{Key: "medication_safety", Type: "knowledge", Title: "说明用药安全原则", Content: "用药需要结合物种、年龄、体重、既往病史和必要检查,由宠物医生评估后确定。请勿自行使用人用药、增加剂量或混用药物。", Config: map[string]interface{}{"knowledge_card_id": knowledgeCardID}},
|
||||
{Key: "assessment", Type: "message", Title: "提交医生评估", Content: "信息已记录。请由宠物医生结合体格检查和必要检验判断病因及治疗方案;如症状加重或出现新的急症表现,应立即就医。", Config: map[string]interface{}{}},
|
||||
{Key: "finish", Type: "finish", Title: "完成问诊记录", Content: "本次初步问诊信息已完整记录,请按医生建议安排复诊、检查或治疗。", Config: map[string]interface{}{}},
|
||||
{Key: "emergency_guidance", Type: "knowledge", Title: "告知急症处置原则", Content: "请按知识卡向宠物主人说明急症风险和立即就医要求。", Config: map[string]interface{}{"knowledge_card_id": knowledgeIDs["急症红旗征象"]}},
|
||||
{Key: "emergency_escalate", Type: "escalate", Title: "立即急诊或转诊", Content: "请停止常规线上问诊,建议立即前往最近的宠物急诊并提前联系接诊机构。保持宠物安静和通风,除接诊医生明确指导外,不要自行喂药、催吐或强行喂食,并携带可疑药物、毒物包装及既往资料。", Config: map[string]interface{}{"action": "urgent_referral"}},
|
||||
{Key: "current_status", Type: "form", Title: "补充当前生命状态", Content: "继续了解食欲、饮水、精神、呕吐、腹泻、排尿、排便和实测体温。未测体温时请留空,不要凭触摸估计。", Config: map[string]interface{}{"field_keys": []string{"appetite", "water_intake", "spirit_status", "vomiting", "diarrhea", "urination_status", "stool_status", "measured_temperature"}}},
|
||||
{Key: "medical_history", Type: "form", Title: "补充既往史", Content: "请记录既往疾病和手术、免疫情况、近期所有药物及保健品,以及已知药物过敏或不良反应。", Config: map[string]interface{}{"field_keys": []string{"chronic_disease_history", "vaccination_status", "medication_history", "allergy_history"}}},
|
||||
{Key: "medication_intent", Type: "question", Title: "确认用药诉求", Content: "请确认宠物主人是否正在咨询具体药物、剂量、疗程、续方、漏服或用药后异常。", Config: map[string]interface{}{"field_key": "wants_medication", "required": true}},
|
||||
{Key: "medication_details", Type: "form", Title: "采集用药详情", Content: "请核对药名和规格、咨询类型、是否由宠物医生开具、是否已经使用,以及已用剂量和时间。无法确认时请查看处方或药品包装,不要猜测。", Config: map[string]interface{}{"field_keys": []string{"medication_request_type", "requested_medication", "prescribed_by_vet", "has_taken_medication", "dose_and_time"}}},
|
||||
{Key: "medication_risk_screen", Type: "form", Title: "筛查用药风险", Content: "请确认是否涉及人用药、可能过量或重复用药,以及用药后异常;如有异常,请记录出现时间和具体表现。", Config: map[string]interface{}{"field_keys": []string{"human_medication_exposure", "suspected_overdose", "adverse_reaction", "adverse_reaction_detail"}}},
|
||||
{Key: "medication_risk_check", Type: "condition", Title: "判断是否存在用药急症风险", Content: "系统根据用药风险信息自动分流。", Config: map[string]interface{}{"risk_level": "high"}},
|
||||
{Key: "medication_escalate", Type: "escalate", Title: "立即转医生或急诊评估", Content: "存在误用人用药、可能过量或用药后异常。请立即转宠物医生评估;如出现呼吸困难、意识异常、抽搐、虚脱或症状快速加重,应立即前往急诊。请保留药品包装、处方和实际用药时间记录。", Config: map[string]interface{}{"action": "medication_risk_referral"}},
|
||||
{Key: "medication_safety", Type: "knowledge", Title: "说明用药安全原则", Content: "请按知识卡说明用药边界,在医生完成评估前不要承诺具体药名、剂量或疗程。", Config: map[string]interface{}{"knowledge_card_id": knowledgeIDs["问诊用药安全原则"]}},
|
||||
{Key: "consultation_boundary", Type: "knowledge", Title: "说明线上问诊边界", Content: "请说明线上沟通的作用和限制,并确认宠物主人理解下一步需要医生评估。", Config: map[string]interface{}{"knowledge_card_id": knowledgeIDs["线上问诊边界"]}},
|
||||
{Key: "assessment", Type: "message", Title: "提交医生评估并明确下一步", Content: "信息已记录。请由宠物医生结合病史、体格检查和必要检验判断后续方案;向宠物主人明确是立即到院、预约就诊、按原处方处理还是居家观察,并记录医生给出的时间要求。", Config: map[string]interface{}{}},
|
||||
{Key: "safety_net", Type: "knowledge", Title: "确认观察和复诊提示", Content: "请按知识卡说明观察指标、复诊时间和立即就医触发条件,并请宠物主人复述确认。", Config: map[string]interface{}{"knowledge_card_id": knowledgeIDs["观察与复诊提示"]}},
|
||||
{Key: "finish", Type: "finish", Title: "完成问诊记录", Content: "本次初步问诊信息已记录,请按宠物医生给出的时间和方式安排检查、复诊或治疗;情况变化时及时重新联系或立即就医。", Config: map[string]interface{}{}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,22 +398,41 @@ func petEdges() []edgeDefinition {
|
||||
map[string]interface{}{"field": "breathing_difficulty", "operator": "equals", "value": true},
|
||||
map[string]interface{}{"field": "active_bleeding", "operator": "equals", "value": true},
|
||||
map[string]interface{}{"field": "convulsion", "operator": "equals", "value": true},
|
||||
map[string]interface{}{"field": "severe_trauma", "operator": "equals", "value": true},
|
||||
map[string]interface{}{"field": "unable_to_urinate", "operator": "equals", "value": true},
|
||||
map[string]interface{}{"field": "toxin_exposure", "operator": "equals", "value": true},
|
||||
map[string]interface{}{"field": "repeated_vomiting", "operator": "equals", "value": true},
|
||||
map[string]interface{}{"field": "severe_pain", "operator": "equals", "value": true},
|
||||
map[string]interface{}{"field": "heatstroke_sign", "operator": "equals", "value": true},
|
||||
map[string]interface{}{"field": "unable_to_stand", "operator": "equals", "value": true},
|
||||
map[string]interface{}{"field": "has_emergency_sign", "operator": "equals", "value": true},
|
||||
}
|
||||
medicationRiskRules := []interface{}{
|
||||
map[string]interface{}{"field": "human_medication_exposure", "operator": "equals", "value": true},
|
||||
map[string]interface{}{"field": "suspected_overdose", "operator": "equals", "value": true},
|
||||
map[string]interface{}{"field": "adverse_reaction", "operator": "equals", "value": true},
|
||||
}
|
||||
return []edgeDefinition{
|
||||
{Source: "start", Target: "opening", Condition: map[string]interface{}{}},
|
||||
{Source: "opening", Target: "basic_info", Condition: map[string]interface{}{}},
|
||||
{Source: "opening", Target: "consultation_context", Condition: map[string]interface{}{}},
|
||||
{Source: "consultation_context", Target: "basic_info", Condition: map[string]interface{}{}},
|
||||
{Source: "basic_info", Target: "chief_complaint", Condition: map[string]interface{}{}},
|
||||
{Source: "chief_complaint", Target: "emergency_screen", Condition: map[string]interface{}{}},
|
||||
{Source: "emergency_screen", Target: "emergency_check", Condition: map[string]interface{}{}},
|
||||
{Source: "emergency_check", Target: "emergency_escalate", Condition: map[string]interface{}{"any": emergencyRules}, Priority: 0},
|
||||
{Source: "emergency_check", Target: "history_collection", Condition: map[string]interface{}{}, Priority: 100},
|
||||
{Source: "history_collection", Target: "medication_intent", Condition: map[string]interface{}{}},
|
||||
{Source: "medication_intent", Target: "medication_safety", Condition: map[string]interface{}{"field": "wants_medication", "operator": "equals", "value": true}, Priority: 0},
|
||||
{Source: "medication_intent", Target: "assessment", Condition: map[string]interface{}{}, Priority: 100},
|
||||
{Source: "medication_safety", Target: "assessment", Condition: map[string]interface{}{}},
|
||||
{Source: "assessment", Target: "finish", Condition: map[string]interface{}{}},
|
||||
{Source: "emergency_check", Target: "emergency_guidance", Condition: map[string]interface{}{"any": emergencyRules}, Priority: 0},
|
||||
{Source: "emergency_check", Target: "current_status", Condition: map[string]interface{}{}, Priority: 100},
|
||||
{Source: "emergency_guidance", Target: "emergency_escalate", Condition: map[string]interface{}{}},
|
||||
{Source: "current_status", Target: "medical_history", Condition: map[string]interface{}{}},
|
||||
{Source: "medical_history", Target: "medication_intent", Condition: map[string]interface{}{}},
|
||||
{Source: "medication_intent", Target: "medication_details", Condition: map[string]interface{}{"field": "wants_medication", "operator": "equals", "value": true}, Priority: 0},
|
||||
{Source: "medication_intent", Target: "consultation_boundary", Condition: map[string]interface{}{}, Priority: 100},
|
||||
{Source: "medication_details", Target: "medication_risk_screen", Condition: map[string]interface{}{}},
|
||||
{Source: "medication_risk_screen", Target: "medication_risk_check", Condition: map[string]interface{}{}},
|
||||
{Source: "medication_risk_check", Target: "medication_escalate", Condition: map[string]interface{}{"any": medicationRiskRules}, Priority: 0},
|
||||
{Source: "medication_risk_check", Target: "medication_safety", Condition: map[string]interface{}{}, Priority: 100},
|
||||
{Source: "medication_safety", Target: "consultation_boundary", Condition: map[string]interface{}{}},
|
||||
{Source: "consultation_boundary", Target: "assessment", Condition: map[string]interface{}{}},
|
||||
{Source: "assessment", Target: "safety_net", Condition: map[string]interface{}{}},
|
||||
{Source: "safety_net", Target: "finish", Condition: map[string]interface{}{}},
|
||||
}
|
||||
}
|
||||
|
||||
113
scripts/seed-pet-doctor/main_test.go
Normal file
113
scripts/seed-pet-doctor/main_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/sop"
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
func TestPetDoctorSOPPassesPublishValidation(t *testing.T) {
|
||||
fields := petFieldModels()
|
||||
knowledgeIDs := map[string]uint64{
|
||||
"线上问诊边界": 1,
|
||||
"急症红旗征象": 2,
|
||||
"问诊用药安全原则": 3,
|
||||
"观察与复诊提示": 4,
|
||||
}
|
||||
nodes := petNodeModels(petNodes(knowledgeIDs))
|
||||
edges := petEdgeModels(petEdges())
|
||||
if len(fields) != 47 || len(nodes) != 21 || len(edges) != 21 {
|
||||
t.Fatalf("unexpected definition size: fields=%d nodes=%d edges=%d", len(fields), len(nodes), len(edges))
|
||||
}
|
||||
|
||||
problems := sop.ValidateForPublish("start", nodes, edges, sop.ValidationContext{
|
||||
Fields: fields,
|
||||
PublishedKnowledgeCardIDs: map[uint64]bool{
|
||||
1: true,
|
||||
2: true,
|
||||
3: true,
|
||||
4: true,
|
||||
},
|
||||
})
|
||||
if len(problems) > 0 {
|
||||
t.Fatalf("pet doctor SOP should be publishable, got: %v", problems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetDoctorSOPHasBothRiskEscalations(t *testing.T) {
|
||||
nodes := petNodes(map[string]uint64{
|
||||
"线上问诊边界": 1,
|
||||
"急症红旗征象": 2,
|
||||
"问诊用药安全原则": 3,
|
||||
"观察与复诊提示": 4,
|
||||
})
|
||||
escalations := map[string]bool{}
|
||||
for _, node := range nodes {
|
||||
if node.Type == "escalate" {
|
||||
escalations[node.Key] = true
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"emergency_escalate", "medication_escalate"} {
|
||||
if !escalations[key] {
|
||||
t.Fatalf("missing risk escalation node %s", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameJSONIgnoresObjectOrderAndWhitespace(t *testing.T) {
|
||||
left := []byte(`{"standard_copy":"安全提示","risk_note":"立即就医"}`)
|
||||
right := []byte(`{ "risk_note": "立即就医", "standard_copy": "安全提示" }`)
|
||||
if !sameJSON(left, right) {
|
||||
t.Fatal("equivalent JSON objects should match")
|
||||
}
|
||||
}
|
||||
|
||||
func petFieldModels() []model.ScenarioField {
|
||||
definitions := petFieldDefinitions()
|
||||
fields := make([]model.ScenarioField, 0, len(definitions))
|
||||
for _, definition := range definitions {
|
||||
options, _ := json.Marshal(definition.Options)
|
||||
validation, _ := json.Marshal(definition.Validation)
|
||||
fields = append(fields, model.ScenarioField{
|
||||
FieldKey: definition.Key,
|
||||
FieldName: definition.Name,
|
||||
FieldType: definition.Type,
|
||||
Required: definition.Required,
|
||||
Options: datatypes.JSON(options),
|
||||
Validation: datatypes.JSON(validation),
|
||||
})
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func petNodeModels(definitions []nodeDefinition) []model.SOPNode {
|
||||
nodes := make([]model.SOPNode, 0, len(definitions))
|
||||
for _, definition := range definitions {
|
||||
config, _ := json.Marshal(definition.Config)
|
||||
nodes = append(nodes, model.SOPNode{
|
||||
NodeKey: definition.Key,
|
||||
Type: definition.Type,
|
||||
Title: definition.Title,
|
||||
Content: definition.Content,
|
||||
Config: datatypes.JSON(config),
|
||||
})
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
func petEdgeModels(definitions []edgeDefinition) []model.SOPEdge {
|
||||
edges := make([]model.SOPEdge, 0, len(definitions))
|
||||
for _, definition := range definitions {
|
||||
condition, _ := json.Marshal(definition.Condition)
|
||||
edges = append(edges, model.SOPEdge{
|
||||
SourceNodeKey: definition.Source,
|
||||
TargetNodeKey: definition.Target,
|
||||
Condition: datatypes.JSON(condition),
|
||||
Priority: definition.Priority,
|
||||
})
|
||||
}
|
||||
return edges
|
||||
}
|
||||
1
web/dist/assets/DeleteOutlined-Bt9-1HZZ.js
vendored
Normal file
1
web/dist/assets/DeleteOutlined-Bt9-1HZZ.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{F as e,a as t}from"./client-Bk7n1O1W.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M360 184h-8c4.4 0 8-3.6 8-8v8h304v-8c0 4.4 3.6 8 8 8h-8v72h72v-80c0-35.3-28.7-64-64-64H352c-35.3 0-64 28.7-64 64v80h72v-72zm504 72H160c-17.7 0-32 14.3-32 32v32c0 4.4 3.6 8 8 8h60.4l24.7 523c1.6 34.1 29.8 61 63.9 61h454c34.2 0 62.3-26.8 63.9-61l24.7-523H888c4.4 0 8-3.6 8-8v-32c0-17.7-14.3-32-32-32zM731.3 840H292.7l-24.2-512h487l-24.2 512z`}}]},name:`delete`,theme:`outlined`};function r(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){i(e,t,n[t])})}return e}function i(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var a=function(i,a){var o=r({},i,a.attrs);return e(t,r({},o,{icon:n}),null)};a.displayName=`DeleteOutlined`,a.inheritAttrs=!1;export{a as t};
|
||||
37
web/dist/assets/DeleteOutlined-CRNedxr9.js
vendored
37
web/dist/assets/DeleteOutlined-CRNedxr9.js
vendored
File diff suppressed because one or more lines are too long
1
web/dist/assets/ExecuteView--myIljT9.css
vendored
1
web/dist/assets/ExecuteView--myIljT9.css
vendored
@@ -1 +0,0 @@
|
||||
.execution-shell[data-v-e2671b1e]{max-width:1220px}.sop-catalog[data-v-e2671b1e]{grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;display:grid}.sop-entry[data-v-e2671b1e]{text-align:left;cursor:pointer;grid-template-columns:40px 1fr 42px;align-items:start;gap:14px;min-height:150px;padding:22px;display:grid}.sop-entry[data-v-e2671b1e]:hover{border-color:#9ac8b8;box-shadow:0 8px 24px #20282512}.entry-seq[data-v-e2671b1e]{color:#8c9893;font-family:monospace;font-size:12px}.sop-entry small[data-v-e2671b1e]{color:var(--green);font-weight:650}.sop-entry h2[data-v-e2671b1e]{margin:8px 0 7px;font-family:Noto Serif SC,serif;font-size:20px}.sop-entry p[data-v-e2671b1e]{color:var(--muted);margin:0;line-height:1.6}.play[data-v-e2671b1e]{color:#fff;background:#202825;border-radius:4px;place-items:center;width:38px;height:38px;font-size:18px;display:grid}.run-workspace[data-v-e2671b1e]{grid-template-columns:270px minmax(0,1fr);gap:18px;display:grid}.run-context[data-v-e2671b1e]{color:#fff;background:#202825;border-radius:6px;align-self:start;padding:24px;position:sticky;top:86px}.run-label[data-v-e2671b1e]{color:#72c9ab;font-size:11px;font-weight:700}.run-context h2[data-v-e2671b1e]{margin:12px 0 8px;font-family:Noto Serif SC,serif}.run-context>p[data-v-e2671b1e]{color:#aebdb7;margin:0 0 28px;line-height:1.6}.context-meta[data-v-e2671b1e]{border-top:1px solid #3a4742;justify-content:space-between;align-items:center;padding:13px 0;display:flex}.context-meta span[data-v-e2671b1e]{color:#9eada7;font-size:12px}.privacy-note[data-v-e2671b1e]{color:#889b93;gap:8px;margin-top:28px;font-size:11px;display:flex}.conversation[data-v-e2671b1e]{min-height:590px;padding:30px 34px}.conversation-progress[data-v-e2671b1e]{color:#66736d;align-items:center;gap:8px;font-size:12px;display:flex}.conversation-progress span[data-v-e2671b1e]{background:#d08736;border-radius:50%;width:8px;height:8px;box-shadow:0 0 0 4px #faefe1}.conversation-progress span.done[data-v-e2671b1e]{background:var(--green);box-shadow:0 0 0 4px #e4f1ec}.node-content[data-v-e2671b1e]{padding:44px 0 26px}.node-content small[data-v-e2671b1e]{color:var(--green);font-size:10px;font-weight:800}.node-content h1[data-v-e2671b1e]{margin:8px 0 22px;font-family:Noto Serif SC,serif;font-size:28px}.node-content blockquote[data-v-e2671b1e]{color:#29342f;border-left:3px solid var(--green);background:#f2f6f4;margin:0;padding:18px 20px;font-size:17px;line-height:1.8}.answer-form[data-v-e2671b1e]{max-width:680px}.choice-group[data-v-e2671b1e]{flex-wrap:wrap;display:flex}.run-actions[data-v-e2671b1e]{border-top:1px solid var(--line);color:var(--muted);justify-content:space-between;align-items:center;gap:16px;margin:24px -34px -30px;padding:18px 34px;font-size:12px;display:flex}.completed-state[data-v-e2671b1e]{text-align:center;padding:40px 0}.completed-state>span[data-v-e2671b1e]{color:var(--green);font-size:50px}.completed-state h3[data-v-e2671b1e]{margin:14px 0 8px;font-family:Noto Serif SC,serif;font-size:24px}.completed-state p[data-v-e2671b1e]{color:var(--muted);margin:0 0 24px}.quick-feedback[data-v-e2671b1e]{text-align:left;border-top:1px solid var(--line);border-bottom:1px solid var(--line);background:#f5f8f6;grid-template-columns:180px 1fr auto;align-items:center;gap:12px;max-width:620px;margin:0 auto 18px;padding:16px;display:grid}.quick-feedback>span[data-v-e2671b1e]{font-size:12px;font-weight:650}.quick-feedback[data-v-e2671b1e] .ant-rate{font-size:18px}.quick-feedback[data-v-e2671b1e] .ant-input{grid-column:1/3}.feedback-thanks[data-v-e2671b1e]{max-width:520px;color:var(--green);background:#edf6f2;margin:0 auto 18px;padding:12px;font-size:12px}@media (width<=800px){.sop-catalog[data-v-e2671b1e],.run-workspace[data-v-e2671b1e]{grid-template-columns:1fr}.run-context[data-v-e2671b1e]{position:static}.conversation[data-v-e2671b1e]{padding:22px}.run-actions[data-v-e2671b1e]{margin:24px -22px -22px;padding:16px 22px}.run-actions span[data-v-e2671b1e]{display:none}.quick-feedback[data-v-e2671b1e]{grid-template-columns:1fr}.quick-feedback[data-v-e2671b1e] .ant-input{grid-column:auto}}
|
||||
1
web/dist/assets/ExecuteView-BrNPNrQz.js
vendored
Normal file
1
web/dist/assets/ExecuteView-BrNPNrQz.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
web/dist/assets/ExecuteView-CJavC20H.js
vendored
1
web/dist/assets/ExecuteView-CJavC20H.js
vendored
File diff suppressed because one or more lines are too long
1
web/dist/assets/ExecuteView-CTC6GSK6.css
vendored
Normal file
1
web/dist/assets/ExecuteView-CTC6GSK6.css
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.execution-shell[data-v-937f7610]{max-width:1220px}.sop-catalog[data-v-937f7610]{grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;display:grid}.sop-entry[data-v-937f7610]{text-align:left;cursor:pointer;grid-template-columns:40px 1fr 42px;align-items:start;gap:14px;min-height:150px;padding:22px;display:grid}.sop-entry[data-v-937f7610]:hover{border-color:#9ac8b8;box-shadow:0 8px 24px #20282512}.entry-seq[data-v-937f7610]{color:#8c9893;font-family:monospace;font-size:12px}.sop-entry small[data-v-937f7610]{color:var(--green);font-weight:650}.sop-entry h2[data-v-937f7610]{margin:8px 0 7px;font-family:Noto Serif SC,serif;font-size:20px}.sop-entry p[data-v-937f7610]{color:var(--muted);margin:0;line-height:1.6}.play[data-v-937f7610]{color:#fff;background:#202825;border-radius:4px;place-items:center;width:38px;height:38px;font-size:18px;display:grid}.run-workspace[data-v-937f7610]{grid-template-columns:270px minmax(0,1fr);gap:18px;display:grid}.run-context[data-v-937f7610]{color:#fff;background:#202825;border-radius:6px;align-self:start;padding:24px;position:sticky;top:86px}.run-label[data-v-937f7610]{color:#72c9ab;font-size:11px;font-weight:700}.run-context h2[data-v-937f7610]{margin:12px 0 8px;font-family:Noto Serif SC,serif}.run-context>p[data-v-937f7610]{color:#aebdb7;margin:0 0 28px;line-height:1.6}.context-meta[data-v-937f7610]{border-top:1px solid #3a4742;justify-content:space-between;align-items:center;padding:13px 0;display:flex}.context-meta span[data-v-937f7610]{color:#9eada7;font-size:12px}.privacy-note[data-v-937f7610]{color:#889b93;gap:8px;margin-top:28px;font-size:11px;display:flex}.conversation[data-v-937f7610]{min-height:590px;padding:30px 34px}.conversation-progress[data-v-937f7610]{color:#66736d;align-items:center;gap:8px;font-size:12px;display:flex}.conversation-progress span[data-v-937f7610]{background:#d08736;border-radius:50%;width:8px;height:8px;box-shadow:0 0 0 4px #faefe1}.conversation-progress span.done[data-v-937f7610]{background:var(--green);box-shadow:0 0 0 4px #e4f1ec}.node-content[data-v-937f7610]{padding:44px 0 26px}.node-content small[data-v-937f7610]{color:var(--green);font-size:10px;font-weight:800}.node-content h1[data-v-937f7610]{margin:8px 0 22px;font-family:Noto Serif SC,serif;font-size:28px}.node-content blockquote[data-v-937f7610]{color:#29342f;border-left:3px solid var(--green);background:#f2f6f4;margin:0;padding:18px 20px;font-size:17px;line-height:1.8}.answer-form[data-v-937f7610]{max-width:680px}.choice-group[data-v-937f7610]{flex-wrap:wrap;display:flex}.run-actions[data-v-937f7610]{border-top:1px solid var(--line);color:var(--muted);justify-content:space-between;align-items:center;gap:16px;margin:24px -34px -30px;padding:18px 34px;font-size:12px;display:flex}.run-actions>div[data-v-937f7610]{align-items:center;gap:9px;display:flex}.completed-state[data-v-937f7610]{text-align:center;padding:40px 0}.completed-state>span[data-v-937f7610]{color:var(--green);font-size:50px}.completed-state h3[data-v-937f7610]{margin:14px 0 8px;font-family:Noto Serif SC,serif;font-size:24px}.completed-state p[data-v-937f7610]{color:var(--muted);margin:0 0 24px}.quick-feedback[data-v-937f7610]{text-align:left;border-top:1px solid var(--line);border-bottom:1px solid var(--line);background:#f5f8f6;grid-template-columns:180px 1fr auto;align-items:center;gap:12px;max-width:620px;margin:0 auto 18px;padding:16px;display:grid}.quick-feedback>span[data-v-937f7610]{font-size:12px;font-weight:650}.quick-feedback[data-v-937f7610] .ant-rate{font-size:18px}.quick-feedback[data-v-937f7610] .ant-input{grid-column:1/3}.feedback-thanks[data-v-937f7610]{max-width:520px;color:var(--green);background:#edf6f2;margin:0 auto 18px;padding:12px;font-size:12px}.knowledge-snapshot[data-v-937f7610]{border-top:1px solid var(--line);border-bottom:1px solid var(--line);margin:0 0 24px}.knowledge-head[data-v-937f7610]{justify-content:space-between;align-items:center;padding:12px 0;display:flex}.knowledge-head span[data-v-937f7610]{color:var(--muted);font-size:11px}.standard-copy[data-v-937f7610],.knowledge-note[data-v-937f7610]{border-left:3px solid var(--green);background:#f2f7f5;padding:14px 16px}.standard-copy small[data-v-937f7610],.knowledge-note small[data-v-937f7610]{font-size:10px;font-weight:750}.standard-copy p[data-v-937f7610],.knowledge-note p[data-v-937f7610]{margin:6px 0 0;line-height:1.7}.knowledge-note[data-v-937f7610]{background:#fbf3f0;border-left-color:#c4573f;margin-top:8px}.knowledge-note.risk[data-v-937f7610]{background:#fbf6eb;border-left-color:#bd7b24}@media (width<=800px){.sop-catalog[data-v-937f7610],.run-workspace[data-v-937f7610]{grid-template-columns:1fr}.run-context[data-v-937f7610]{position:static}.conversation[data-v-937f7610]{padding:22px}.run-actions[data-v-937f7610]{margin:24px -22px -22px;padding:16px 22px}.run-actions span[data-v-937f7610]{display:none}.quick-feedback[data-v-937f7610]{grid-template-columns:1fr}.quick-feedback[data-v-937f7610] .ant-input{grid-column:auto}}
|
||||
1
web/dist/assets/KnowledgeView-BZbzCYjc.js
vendored
Normal file
1
web/dist/assets/KnowledgeView-BZbzCYjc.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
web/dist/assets/KnowledgeView-D2bZOSSf.js
vendored
1
web/dist/assets/KnowledgeView-D2bZOSSf.js
vendored
File diff suppressed because one or more lines are too long
1
web/dist/assets/ReviewCenterView-Cyi3NnR4.js
vendored
1
web/dist/assets/ReviewCenterView-Cyi3NnR4.js
vendored
@@ -1 +0,0 @@
|
||||
import{A as e,Bt as t,F as n,I as r,O as i,P as a,Q as o,Tt as s,Y as c,_t as l,a as u,j as d,k as f,lt as p,n as m,r as h,t as g,tt as _,vt as v}from"./client-Bk7n1O1W.js";import{a as y}from"./config-provider-CxhqvDI_.js";import{n as b,r as x,t as S}from"./EyeOutlined-C1VmLQCG.js";import{h as C}from"./useApi-CROJJdhE-C8LJOf_8.js";var w={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm0 820c-205.4 0-372-166.6-372-372 0-89 31.3-170.8 83.5-234.8l523.3 523.3C682.8 852.7 601 884 512 884zm288.5-137.2L277.2 223.5C341.2 171.3 423 140 512 140c205.4 0 372 166.6 372 372 0 89-31.3 170.8-83.5 234.8z`}}]},name:`stop`,theme:`outlined`};function T(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){E(e,t,n[t])})}return e}function E(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var D=function(e,t){var r=T({},e,t.attrs);return n(u,T({},r,{icon:w}),null)};D.displayName=`StopOutlined`,D.inheritAttrs=!1;var O={class:`page-shell review-page`},k={class:`page-heading`},A={class:`queue-count`},j={class:`surface review-table`},M={key:0,class:`sop-cell`},N={key:2,class:`waiting`},P={key:3,class:`row-actions`},F={class:`review-empty`},I={class:`modal-copy`},L=h(r({__name:`ReviewCenterView`,setup(r){let u=C(),h=v(!1),w=v([]),T=v(null),E=v(!1),L=l({reason:``}),R=[{title:`SOP / 场景`,key:`sop`},{title:`版本`,dataIndex:`version`,width:90},{title:`提交人`,dataIndex:`creator_name`,width:140},{title:`等待时间`,dataIndex:`submitted_at`,width:180},{title:``,key:`action`,width:250}];async function z(){h.value=!0;try{let e=await g.get(`/reviews`);w.value=e.items||[]}catch(e){y.error(m(e))}finally{h.value=!1}}async function B(e){E.value=!0;try{await g.post(`/sops/${e.sop_id}/publish`),y.success(`“${e.sop_name}”已通过并发布`),await z()}catch(e){y.error(m(e))}finally{E.value=!1}}function V(e){T.value=e,L.reason=``}async function H(){if(!T.value||!L.reason.trim())return y.warning(`请填写退回原因`);E.value=!0;try{await g.post(`/sops/${T.value.sop_id}/reject`,{reason:L.reason}),y.success(`已退回编辑者修改`),T.value=null,await z()}catch(e){y.error(m(e))}finally{E.value=!1}}function U(e){let t=Math.max(1,Math.round((Date.now()-new Date(e).getTime())/6e4));if(t<60)return`${t} 分钟`;let n=Math.round(t/60);return n<48?`${n} 小时`:`${Math.round(n/24)} 天`}return c(z),(r,c)=>{let l=_(`a-tag`),m=_(`a-button`),g=_(`a-table`),v=_(`a-textarea`),y=_(`a-modal`);return o(),d(`div`,O,[i(`div`,k,[c[3]||=i(`div`,null,[i(`h1`,null,`审核中心`),i(`p`,null,`检查流程完整性、风险分支和话术口径后再决定发布。`)],-1),i(`div`,A,[n(s(b)),i(`b`,null,t(w.value.length),1),c[2]||=i(`span`,null,`待审核`,-1)])]),i(`section`,j,[c[9]||=i(`div`,{class:`review-guideline`},[i(`span`,null,`审核关注`),i(`b`,null,`字段完整`),i(`i`),i(`b`,null,`条件可达`),i(`i`),i(`b`,null,`高风险有出口`),i(`i`),i(`b`,null,`知识卡已发布`)],-1),n(g,{"row-key":`version_id`,loading:h.value,"data-source":w.value,columns:R,pagination:!1,scroll:{x:850}},{bodyCell:p(({column:r,record:h})=>[r.key===`sop`?(o(),d(`div`,M,[i(`b`,null,t(h.sop_name),1),i(`span`,null,t(h.scenario_name)+` · `+t(h.description||`暂无说明`),1)])):r.dataIndex===`version`?(o(),f(l,{key:1},{default:p(()=>[a(`V`+t(h.version),1)]),_:2},1024)):r.dataIndex===`submitted_at`?(o(),d(`span`,N,t(U(h.submitted_at)),1)):r.key===`action`?(o(),d(`div`,P,[n(m,{onClick:e=>s(u).push(`/sops/${h.sop_id}`)},{default:p(()=>[n(s(S)),c[4]||=a(`查看`,-1)]),_:1},8,[`onClick`]),n(m,{danger:``,onClick:e=>V(h)},{default:p(()=>[n(s(D)),c[5]||=a(`退回`,-1)]),_:1},8,[`onClick`]),n(m,{type:`primary`,loading:E.value,onClick:e=>B(h)},{default:p(()=>[n(s(x)),c[6]||=a(`通过`,-1)]),_:1},8,[`loading`,`onClick`])])):e(``,!0)]),emptyText:p(()=>[i(`div`,F,[n(s(x)),c[7]||=i(`b`,null,`审核队列已清空`,-1),c[8]||=i(`span`,null,`新提交的 SOP 会出现在这里。`,-1)])]),_:1},8,[`loading`,`data-source`])]),n(y,{open:!!T.value,title:`退回修改`,"ok-text":`确认退回`,"confirm-loading":E.value,onOk:H,onCancel:c[1]||=e=>T.value=null},{default:p(()=>[i(`p`,I,`请说明“`+t(T.value?.sop_name)+`”需要调整的具体内容,编辑者可据此修改后重新提交。`,1),n(v,{value:L.reason,"onUpdate:value":c[0]||=e=>L.reason=e,rows:5,maxlength:1e3,"show-count":``,placeholder:`例如:急症判断缺少默认路径,请补充非急症分支。`},null,8,[`value`])]),_:1},8,[`open`,`confirm-loading`])])}}}),[[`__scopeId`,`data-v-88d0e1a8`]]);export{L as default};
|
||||
1
web/dist/assets/ReviewCenterView-DanGoVob.js
vendored
Normal file
1
web/dist/assets/ReviewCenterView-DanGoVob.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{A as e,Bt as t,F as n,I as r,O as i,P as a,Q as o,Tt as s,Y as c,_t as l,j as u,k as d,lt as f,n as p,r as m,t as h,tt as g,vt as _}from"./client-Bk7n1O1W.js";import{a as v}from"./config-provider-CxhqvDI_.js";import{n as y,r as b,t as x}from"./EyeOutlined-C1VmLQCG.js";import{t as S}from"./StopOutlined-X-yne_Zl.js";import{h as C}from"./useApi-CROJJdhE-C8LJOf_8.js";var w={class:`page-shell review-page`},T={class:`page-heading`},E={class:`queue-count`},D={class:`surface review-table`},O={key:0,class:`sop-cell`},k={key:2,class:`waiting`},A={key:3,class:`row-actions`},j={class:`review-empty`},M={class:`modal-copy`},N=m(r({__name:`ReviewCenterView`,setup(r){let m=C(),N=_(!1),P=_([]),F=_(null),I=_(!1),L=l({reason:``}),R=[{title:`SOP / 场景`,key:`sop`},{title:`版本`,dataIndex:`version`,width:90},{title:`提交人`,dataIndex:`creator_name`,width:140},{title:`等待时间`,dataIndex:`submitted_at`,width:180},{title:``,key:`action`,width:250}];async function z(){N.value=!0;try{let e=await h.get(`/reviews`);P.value=e.items||[]}catch(e){v.error(p(e))}finally{N.value=!1}}async function B(e){I.value=!0;try{await h.post(`/sops/${e.sop_id}/publish`),v.success(`“${e.sop_name}”已通过并发布`),await z()}catch(e){v.error(p(e))}finally{I.value=!1}}function V(e){F.value=e,L.reason=``}async function H(){if(!F.value||!L.reason.trim())return v.warning(`请填写退回原因`);I.value=!0;try{await h.post(`/sops/${F.value.sop_id}/reject`,{reason:L.reason}),v.success(`已退回编辑者修改`),F.value=null,await z()}catch(e){v.error(p(e))}finally{I.value=!1}}function U(e){let t=Math.max(1,Math.round((Date.now()-new Date(e).getTime())/6e4));if(t<60)return`${t} 分钟`;let n=Math.round(t/60);return n<48?`${n} 小时`:`${Math.round(n/24)} 天`}return c(z),(r,c)=>{let l=g(`a-tag`),p=g(`a-button`),h=g(`a-table`),_=g(`a-textarea`),v=g(`a-modal`);return o(),u(`div`,w,[i(`div`,T,[c[3]||=i(`div`,null,[i(`h1`,null,`审核中心`),i(`p`,null,`检查流程完整性、风险分支和话术口径后再决定发布。`)],-1),i(`div`,E,[n(s(y)),i(`b`,null,t(P.value.length),1),c[2]||=i(`span`,null,`待审核`,-1)])]),i(`section`,D,[c[9]||=i(`div`,{class:`review-guideline`},[i(`span`,null,`审核关注`),i(`b`,null,`字段完整`),i(`i`),i(`b`,null,`条件可达`),i(`i`),i(`b`,null,`高风险有出口`),i(`i`),i(`b`,null,`知识卡已发布`)],-1),n(h,{"row-key":`version_id`,loading:N.value,"data-source":P.value,columns:R,pagination:!1,scroll:{x:850}},{bodyCell:f(({column:r,record:h})=>[r.key===`sop`?(o(),u(`div`,O,[i(`b`,null,t(h.sop_name),1),i(`span`,null,t(h.scenario_name)+` · `+t(h.description||`暂无说明`),1)])):r.dataIndex===`version`?(o(),d(l,{key:1},{default:f(()=>[a(`V`+t(h.version),1)]),_:2},1024)):r.dataIndex===`submitted_at`?(o(),u(`span`,k,t(U(h.submitted_at)),1)):r.key===`action`?(o(),u(`div`,A,[n(p,{onClick:e=>s(m).push(`/sops/${h.sop_id}`)},{default:f(()=>[n(s(x)),c[4]||=a(`查看`,-1)]),_:1},8,[`onClick`]),n(p,{danger:``,onClick:e=>V(h)},{default:f(()=>[n(s(S)),c[5]||=a(`退回`,-1)]),_:1},8,[`onClick`]),n(p,{type:`primary`,loading:I.value,onClick:e=>B(h)},{default:f(()=>[n(s(b)),c[6]||=a(`通过`,-1)]),_:1},8,[`loading`,`onClick`])])):e(``,!0)]),emptyText:f(()=>[i(`div`,j,[n(s(b)),c[7]||=i(`b`,null,`审核队列已清空`,-1),c[8]||=i(`span`,null,`新提交的 SOP 会出现在这里。`,-1)])]),_:1},8,[`loading`,`data-source`])]),n(v,{open:!!F.value,title:`退回修改`,"ok-text":`确认退回`,"confirm-loading":I.value,onOk:H,onCancel:c[1]||=e=>F.value=null},{default:f(()=>[i(`p`,M,`请说明“`+t(F.value?.sop_name)+`”需要调整的具体内容,编辑者可据此修改后重新提交。`,1),n(_,{value:L.reason,"onUpdate:value":c[0]||=e=>L.reason=e,rows:5,maxlength:1e3,"show-count":``,placeholder:`例如:急症判断缺少默认路径,请补充非急症分支。`},null,8,[`value`])]),_:1},8,[`open`,`confirm-loading`])])}}}),[[`__scopeId`,`data-v-88d0e1a8`]]);export{N as default};
|
||||
1
web/dist/assets/RunDetailView-BcBBNOh1.js
vendored
1
web/dist/assets/RunDetailView-BcBBNOh1.js
vendored
File diff suppressed because one or more lines are too long
1
web/dist/assets/RunDetailView-CnZ1R0eC.css
vendored
1
web/dist/assets/RunDetailView-CnZ1R0eC.css
vendored
@@ -1 +0,0 @@
|
||||
.run-detail-page[data-v-65fb6a52]{max-width:1320px}.back-link[data-v-65fb6a52]{height:auto;margin:0 0 14px;padding:0}.detail-heading[data-v-65fb6a52]{justify-content:space-between;align-items:flex-end;gap:24px;margin-bottom:20px;display:flex}.run-code[data-v-65fb6a52]{color:var(--green);font-family:monospace;font-size:12px;font-weight:750}.detail-heading h1[data-v-65fb6a52]{margin:7px 0 5px;font-family:Noto Serif SC,serif;font-size:28px}.detail-heading p[data-v-65fb6a52]{color:var(--muted);margin:0}.result-block[data-v-65fb6a52]{border-left:3px solid;align-items:center;gap:12px;min-width:210px;padding:15px 17px;display:flex}.result-block>span[data-v-65fb6a52]:first-child{font-size:25px}.result-block>span[data-v-65fb6a52]:last-child{flex-direction:column;display:flex}.result-block small[data-v-65fb6a52]{font-size:10px}.result-block b[data-v-65fb6a52]{margin-top:3px}.result-block.success[data-v-65fb6a52]{color:#17664f;background:#eaf4f0;border-color:#25866a}.result-block.warning[data-v-65fb6a52]{color:#8d5b16;background:#fbf2e3;border-color:#c08026}.detail-layout[data-v-65fb6a52]{grid-template-columns:minmax(0,1fr) 330px;gap:16px;display:grid}.timeline-panel[data-v-65fb6a52]{min-height:560px;padding:22px 24px}.section-title[data-v-65fb6a52]{justify-content:space-between;align-items:baseline;gap:12px;margin-bottom:22px;display:flex}.section-title span[data-v-65fb6a52]{font-weight:700}.section-title small[data-v-65fb6a52]{color:var(--muted);font-size:11px}.run-timeline[data-v-65fb6a52]{padding-top:5px}.event-head[data-v-65fb6a52]{align-items:center;gap:9px;min-height:27px;display:flex}.event-head time[data-v-65fb6a52]{color:#8b9691;margin-left:auto;font-size:11px}.run-timeline p[data-v-65fb6a52]{color:#56635d;margin:8px 0 0;line-height:1.65}.event-answers[data-v-65fb6a52]{grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;margin-top:10px;display:grid}.event-answers span[data-v-65fb6a52]{background:#f4f7f5;border-left:2px solid #a6cbbd;justify-content:space-between;gap:10px;padding:8px 10px;display:flex}.event-answers small[data-v-65fb6a52]{color:var(--muted)}.event-answers b[data-v-65fb6a52]{font-size:12px}.detail-aside[data-v-65fb6a52]{flex-direction:column;gap:16px;display:flex}.summary-panel[data-v-65fb6a52],.answer-panel[data-v-65fb6a52]{padding:20px}.summary-panel dl[data-v-65fb6a52]{margin:0}.summary-panel dl>div[data-v-65fb6a52]{border-bottom:1px solid #edf0ee;justify-content:space-between;gap:15px;padding:10px 0;display:flex}.summary-panel dl>div[data-v-65fb6a52]:last-child{border-bottom:0}.summary-panel dt[data-v-65fb6a52]{color:var(--muted);font-size:12px}.summary-panel dd[data-v-65fb6a52]{text-align:right;margin:0;font-size:12px}.answer-list>div[data-v-65fb6a52]{border-bottom:1px solid #edf0ee;flex-direction:column;padding:10px 0;display:flex}.answer-list>div[data-v-65fb6a52]:last-child{border-bottom:0}.answer-list span[data-v-65fb6a52]{color:var(--muted);font-size:11px}.answer-list b[data-v-65fb6a52]{margin-top:4px;font-size:13px}.aside-empty[data-v-65fb6a52]{color:#929d98;text-align:center;padding:24px 0;font-size:12px}.feedback-band[data-v-65fb6a52]{margin-top:16px;padding:22px 24px}.feedback-list[data-v-65fb6a52]{grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;display:grid}.feedback-list article[data-v-65fb6a52]{border-top:1px solid var(--line);padding:14px 0}.feedback-list article>div[data-v-65fb6a52]{justify-content:space-between;align-items:center;display:flex}.feedback-list[data-v-65fb6a52] .ant-rate{font-size:15px}.feedback-list p[data-v-65fb6a52]{color:#54615b;margin:9px 0}.feedback-list time[data-v-65fb6a52]{color:#929d98;font-size:11px}.feedback-form[data-v-65fb6a52]{border-top:1px solid var(--line);grid-template-columns:220px minmax(0,1fr) auto;align-items:center;gap:14px;padding-top:16px;display:grid}.feedback-form>div[data-v-65fb6a52]{flex-direction:column;gap:6px;display:flex}.feedback-form>div>span[data-v-65fb6a52]{font-size:12px;font-weight:650}.feedback-form[data-v-65fb6a52] .ant-rate{font-size:20px}@media (width<=900px){.detail-layout[data-v-65fb6a52]{grid-template-columns:1fr}.detail-aside[data-v-65fb6a52]{grid-template-columns:1fr 1fr;display:grid}.feedback-form[data-v-65fb6a52],.feedback-list[data-v-65fb6a52]{grid-template-columns:1fr}}@media (width<=620px){.detail-heading[data-v-65fb6a52]{flex-direction:column;align-items:flex-start}.result-block[data-v-65fb6a52]{width:100%}.detail-aside[data-v-65fb6a52]{grid-template-columns:1fr}.timeline-panel[data-v-65fb6a52]{padding:18px}.event-head[data-v-65fb6a52]{flex-wrap:wrap;align-items:flex-start}.event-head time[data-v-65fb6a52]{width:100%;margin:0}.event-answers[data-v-65fb6a52]{grid-template-columns:1fr}}
|
||||
1
web/dist/assets/RunDetailView-W8qPXltZ.js
vendored
Normal file
1
web/dist/assets/RunDetailView-W8qPXltZ.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
web/dist/assets/RunDetailView-z649jgwe.css
vendored
Normal file
1
web/dist/assets/RunDetailView-z649jgwe.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
web/dist/assets/RunHistoryView-BHDOSNhY.css
vendored
Normal file
1
web/dist/assets/RunHistoryView-BHDOSNhY.css
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.history-toolbar[data-v-d3f4eec1]{border-bottom:1px solid var(--line);justify-content:space-between;align-items:center;gap:12px;min-height:58px;padding:10px 14px;display:flex}.filter-set[data-v-d3f4eec1]{color:#7a8781;flex-wrap:wrap;align-items:center;gap:9px;display:flex}.filter-set[data-v-d3f4eec1] .ant-select{width:150px}.filter-set[data-v-d3f4eec1] .ant-picker{width:240px}.run-sop[data-v-d3f4eec1]{flex-direction:column;display:flex}.run-sop small[data-v-d3f4eec1]{color:var(--muted);margin-top:4px}.surface[data-v-d3f4eec1] .ant-table-row{cursor:pointer}.surface[data-v-d3f4eec1] .ant-table-row:hover .row-arrow{color:var(--green);transform:translate(2px)}code[data-v-d3f4eec1]{color:#17654f;background:#eef5f2;border-radius:3px;padding:2px 5px}.row-arrow[data-v-d3f4eec1]{color:#9ba6a1;transition:all .16s}@media (width<=700px){.history-toolbar[data-v-d3f4eec1]{align-items:flex-start}.filter-set[data-v-d3f4eec1]{width:100%}.filter-set[data-v-d3f4eec1] .ant-select{width:calc(50% - 18px)}.filter-set[data-v-d3f4eec1] .ant-picker{width:100%}}
|
||||
1
web/dist/assets/RunHistoryView-CZZ4hEox.css
vendored
1
web/dist/assets/RunHistoryView-CZZ4hEox.css
vendored
@@ -1 +0,0 @@
|
||||
.history-toolbar[data-v-681d0563]{border-bottom:1px solid var(--line);justify-content:space-between;align-items:center;gap:12px;min-height:58px;padding:10px 14px;display:flex}.filter-set[data-v-681d0563]{color:#7a8781;align-items:center;gap:9px;display:flex}.filter-set[data-v-681d0563] .ant-select{width:160px}.surface[data-v-681d0563] .ant-table-row{cursor:pointer}.surface[data-v-681d0563] .ant-table-row:hover .row-arrow{color:var(--green);transform:translate(2px)}code[data-v-681d0563]{color:#17654f;background:#eef5f2;border-radius:3px;padding:2px 5px}.row-arrow[data-v-681d0563]{color:#9ba6a1;transition:all .16s}@media (width<=600px){.history-toolbar[data-v-681d0563]{align-items:flex-start}.filter-set[data-v-681d0563]{flex-wrap:wrap}.filter-set[data-v-681d0563] .ant-select{width:138px}}
|
||||
1
web/dist/assets/RunHistoryView-D0sKlaWk.js
vendored
Normal file
1
web/dist/assets/RunHistoryView-D0sKlaWk.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
web/dist/assets/RunHistoryView-vkkaahbp.js
vendored
1
web/dist/assets/RunHistoryView-vkkaahbp.js
vendored
@@ -1 +0,0 @@
|
||||
import{A as e,Bt as t,F as n,I as r,O as i,P as a,Q as o,S as s,Tt as c,Y as l,_t as u,a as d,j as f,k as p,lt as m,n as h,r as g,t as _,tt as v,vt as y}from"./client-Bk7n1O1W.js";import{a as b}from"./config-provider-CxhqvDI_.js";import{n as x,t as S}from"./ReloadOutlined-Cih9hKpY.js";import{h as C}from"./useApi-CROJJdhE-C8LJOf_8.js";var w={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M880.1 154H143.9c-24.5 0-39.8 26.7-27.5 48L349 597.4V838c0 17.7 14.2 32 31.8 32h262.4c17.6 0 31.8-14.3 31.8-32V597.4L907.7 202c12.2-21.3-3.1-48-27.6-48zM603.4 798H420.6V642h182.9v156zm9.6-236.6l-9.5 16.6h-183l-9.5-16.6L212.7 226h598.6L613 561.4z`}}]},name:`filter`,theme:`outlined`};function T(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){E(e,t,n[t])})}return e}function E(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var D=function(e,t){var r=T({},e,t.attrs);return n(d,T({},r,{icon:w}),null)};D.displayName=`FilterOutlined`,D.inheritAttrs=!1;var O={class:`page-shell`},k={class:`surface`},A={class:`history-toolbar`},j={class:`filter-set`},M={key:0},N=g(r({__name:`RunHistoryView`,setup(r){let d=C(),g=y(!1),w=y([]),T=y(0),E=y(1),N=y(20),P=u({status:``,result:``}),F=[{title:`执行编号`,dataIndex:`id`,width:130},{title:`SOP`,dataIndex:`sop_name`},{title:`状态`,dataIndex:`status`,width:110},{title:`结果`,dataIndex:`result`,width:130},{title:`开始时间`,dataIndex:`started_at`,width:190},{title:`完成时间`,dataIndex:`completed_at`,width:190},{title:``,key:`action`,width:54}];async function I(e=!1){e&&(E.value=1),g.value=!0;try{let e=await _.get(`/runs`,{params:{page:E.value,page_size:N.value,status:P.status||void 0,result:P.result||void 0}});w.value=e.items||[],T.value=e.total}catch(e){b.error(h(e))}finally{g.value=!1}}function L(e){return e===`completed`?`已完成`:`进行中`}function R(e){return{finish:`正常结束`,escalate:`转人工 / 转诊`,manual:`人工结束`}[e]||e||`-`}function z(e,t){E.value=e,N.value=t,I()}function B(e){return{onClick:()=>d.push(`/runs/${e.id}`)}}function V(e){z(e.current||1,e.pageSize||20)}return l(I),(r,l)=>{let u=v(`a-select`),d=v(`a-button`),h=v(`a-tag`),_=v(`a-table`);return o(),f(`div`,O,[l[6]||=i(`div`,{class:`page-heading`},[i(`div`,null,[i(`h1`,null,`执行记录`),i(`p`,null,`按结果筛选实际执行,进入详情复盘每一步回答和分流。`)])],-1),i(`section`,k,[i(`div`,A,[i(`div`,j,[n(c(D)),n(u,{value:P.status,"onUpdate:value":l[0]||=e=>P.status=e,"allow-clear":``,placeholder:`全部状态`,options:[{value:`running`,label:`进行中`},{value:`completed`,label:`已完成`}],onChange:l[1]||=e=>I(!0)},null,8,[`value`]),n(u,{value:P.result,"onUpdate:value":l[2]||=e=>P.result=e,"allow-clear":``,placeholder:`全部结果`,options:[{value:`finish`,label:`正常结束`},{value:`escalate`,label:`转人工 / 转诊`},{value:`manual`,label:`人工结束`}],onChange:l[3]||=e=>I(!0)},null,8,[`value`])]),n(d,{type:`text`,"aria-label":`刷新执行记录`,onClick:l[4]||=e=>I()},{default:m(()=>[n(c(S))]),_:1})]),n(_,{"row-key":`id`,loading:g.value,"data-source":w.value,columns:F,pagination:{current:E.value,pageSize:N.value,total:T.value,showSizeChanger:!0,showTotal:e=>`共 ${e} 条`},scroll:{x:980},"custom-row":B,onChange:V},{bodyCell:m(({column:n,record:r})=>[n.dataIndex===`id`?(o(),f(`code`,M,`RUN-`+t(String(r.id).padStart(5,`0`)),1)):n.dataIndex===`status`?(o(),p(h,{key:1,color:r.status===`completed`?`green`:`orange`},{default:m(()=>[a(t(L(r.status)),1)]),_:2},1032,[`color`])):n.dataIndex===`result`?(o(),f(s,{key:2},[a(t(R(r.result)),1)],64)):n.dataIndex===`started_at`||n.dataIndex===`completed_at`?(o(),f(s,{key:3},[a(t(r[n.dataIndex]?new Date(r[n.dataIndex]).toLocaleString(`zh-CN`):`-`),1)],64)):n.key===`action`?(o(),p(c(x),{key:4,class:`row-arrow`})):e(``,!0)]),emptyText:m(()=>[...l[5]||=[i(`div`,{class:`empty-copy`},`当前筛选条件下没有执行记录。`,-1)]]),_:1},8,[`loading`,`data-source`,`pagination`])])])}}}),[[`__scopeId`,`data-v-681d0563`]]);export{N as default};
|
||||
1
web/dist/assets/SOPEditorView-C-aX-vfz.css
vendored
1
web/dist/assets/SOPEditorView-C-aX-vfz.css
vendored
File diff suppressed because one or more lines are too long
1
web/dist/assets/SOPEditorView-CnsIl3I7.css
vendored
Normal file
1
web/dist/assets/SOPEditorView-CnsIl3I7.css
vendored
Normal file
File diff suppressed because one or more lines are too long
3
web/dist/assets/SOPEditorView-D6Kadvbx.js
vendored
3
web/dist/assets/SOPEditorView-D6Kadvbx.js
vendored
File diff suppressed because one or more lines are too long
3
web/dist/assets/SOPEditorView-DSYlIMXk.js
vendored
Normal file
3
web/dist/assets/SOPEditorView-DSYlIMXk.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
web/dist/assets/StopOutlined-X-yne_Zl.js
vendored
Normal file
1
web/dist/assets/StopOutlined-X-yne_Zl.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
import{F as e,a as t}from"./client-Bk7n1O1W.js";var n={icon:{tag:`svg`,attrs:{viewBox:`64 64 896 896`,focusable:`false`},children:[{tag:`path`,attrs:{d:`M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm0 820c-205.4 0-372-166.6-372-372 0-89 31.3-170.8 83.5-234.8l523.3 523.3C682.8 852.7 601 884 512 884zm288.5-137.2L277.2 223.5C341.2 171.3 423 140 512 140c205.4 0 372 166.6 372 372 0 89-31.3 170.8-83.5 234.8z`}}]},name:`stop`,theme:`outlined`};function r(e){for(var t=1;t<arguments.length;t++){var n=arguments[t]==null?{}:Object(arguments[t]),r=Object.keys(n);typeof Object.getOwnPropertySymbols==`function`&&(r=r.concat(Object.getOwnPropertySymbols(n).filter(function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),r.forEach(function(t){i(e,t,n[t])})}return e}function i(e,t,n){return t in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}var a=function(i,a){var o=r({},i,a.attrs);return e(t,r({},o,{icon:n}),null)};a.displayName=`StopOutlined`,a.inheritAttrs=!1;export{a as t};
|
||||
File diff suppressed because one or more lines are too long
37
web/dist/assets/modal-C5Vo2Ug5.js
vendored
Normal file
37
web/dist/assets/modal-C5Vo2Ug5.js
vendored
Normal file
File diff suppressed because one or more lines are too long
5
web/dist/index.html
vendored
5
web/dist/index.html
vendored
@@ -5,17 +5,18 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#202825" />
|
||||
<title>销冠 SOP 平台</title>
|
||||
<script type="module" crossorigin src="/assets/index-BeO8QXu8.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-DpE4cxr4.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/client-Bk7n1O1W.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/config-provider-CxhqvDI_.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/auth-D6Tj4g1X.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/DeleteOutlined-CRNedxr9.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/modal-C5Vo2Ug5.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/EyeOutlined-C1VmLQCG.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/SearchOutlined-D6c8vQZ_.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/ReloadOutlined-Cih9hKpY.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/PlusOutlined-CosM7zFe.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/ArrowLeftOutlined-B1KoeknA.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/AppstoreOutlined-BD2QJkU0.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/DeleteOutlined-Bt9-1HZZ.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/EditOutlined-BIUW-zYI.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/BookOutlined-Canuw-q-.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/HistoryOutlined-ABEqcIfz.js">
|
||||
|
||||
@@ -4,13 +4,14 @@ export interface Scenario extends BaseEntity { tenant_id: number; name: string;
|
||||
export interface ScenarioField extends BaseEntity { scenario_id: number; field_key: string; field_name: string; field_type: string; required: boolean; options: unknown[]; validation: Record<string, unknown>; sort_order: number }
|
||||
export interface SOP extends BaseEntity { scenario_id: number; name: string; description: string; status: string }
|
||||
export interface SOPVersion extends BaseEntity { sop_id: number; version: number; status: string; start_node_key: string; published_at?: string; created_by?:number; reviewed_by?:number; creator_name?:string; reviewer_name?:string }
|
||||
export interface SOPNode extends BaseEntity { node_key: string; type: string; title: string; content: string; config: Record<string, any>; position_x: number; position_y: number }
|
||||
export interface KnowledgeSnapshot { card_id:number; card_version_id:number; version:number; title:string; standard_copy:string; forbidden_copy:string; risk_note:string }
|
||||
export interface SOPNode extends BaseEntity { node_key: string; type: string; title: string; content: string; config: Record<string, any>; position_x: number; position_y: number; knowledge?:KnowledgeSnapshot }
|
||||
export interface SOPEdge extends BaseEntity { source_node_key: string; target_node_key: string; condition: Record<string, any>; priority: number }
|
||||
export interface SOPRun extends BaseEntity { sop_id: number; sop_version_id: number; current_node_key: string; status: string; answers: Record<string, any>; result: string; started_at: string; completed_at?: string; sop_name?: string }
|
||||
export interface SOPRun extends BaseEntity { sop_id: number; sop_version_id: number; operator_id:number; current_node_key: string; status: string; answers: Record<string, any>; result: string; started_at: string; completed_at?: string; sop_name?: string; scenario_name?:string; version?:number; operator_name?:string }
|
||||
export interface KnowledgeCard extends BaseEntity { scenario_id: number; title: string; status: string; version?: number; content?: Record<string, any> }
|
||||
export interface KnowledgeCardVersion extends BaseEntity { knowledge_card_id:number; version:number; status:string; content:Record<string,any> }
|
||||
export interface ReviewItem { sop_id:number; sop_name:string; description:string; scenario_id:number; scenario_name:string; version_id:number; version:number; creator_id:number; creator_name:string; submitted_at:string }
|
||||
export interface RunEvent extends BaseEntity { run_id:number; node_key:string; action:string; payload:Record<string,any>; node_title:string; node_type:string; node_content:string }
|
||||
export interface RunEvent extends BaseEntity { run_id:number; node_key:string; action:string; payload:Record<string,any>; node_title:string; node_type:string; node_content:string; knowledge?:KnowledgeSnapshot }
|
||||
export interface RunFeedback extends BaseEntity { run_id:number; user_id:number; score:number; comment:string; user_name:string }
|
||||
export interface RunDetail extends SOPRun { scenario_name:string; version:number; operator_name:string }
|
||||
export interface Role extends BaseEntity { name:string; code:string; permissions:string[] }
|
||||
|
||||
@@ -1,22 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { CheckCircleOutlined, PlayCircleOutlined, SafetyCertificateOutlined } from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||||
import { CheckCircleOutlined, PlayCircleOutlined, SafetyCertificateOutlined, StopOutlined } from '@ant-design/icons-vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import type { FormInstance } from 'ant-design-vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api, apiMessage } from '@/api/client'
|
||||
import type { ScenarioField, SOPNode, SOPRun } from '@/types'
|
||||
|
||||
interface PublishedSOP { id:number; name:string; description:string; scenario_id:number; scenario_name:string; version:number }
|
||||
interface RunView { run:SOPRun; node:SOPNode; fields:ScenarioField[] }
|
||||
const route=useRoute();const router=useRouter()
|
||||
const loading=ref(true);const sops=ref<PublishedSOP[]>([]);const active=ref<RunView|null>(null);const answers=reactive<Record<string,any>>({});const submitting=ref(false)
|
||||
const answerForm=ref<FormInstance>()
|
||||
const feedback=reactive({score:5,comment:''});const feedbackSent=ref(false)
|
||||
const currentConfig=computed(()=>active.value?.node.config||{})
|
||||
const currentFields=computed(()=>{if(!active.value)return[];const node=active.value.node;if(node.type==='question'||node.type==='choice')return active.value.fields.filter(f=>f.field_key===currentConfig.value.field_key);if(node.type==='form')return active.value.fields.filter(f=>(currentConfig.value.field_keys||[]).includes(f.field_key));return[]})
|
||||
async function load(){loading.value=true;try{sops.value=(await api.get<{items:PublishedSOP[]}>('/published-sops')).items}catch(error){message.error(apiMessage(error))}finally{loading.value=false}}
|
||||
async function start(sopID:number){try{active.value=await api.post<RunView>('/runs',{sop_id:sopID});Object.keys(answers).forEach(k=>delete answers[k]);Object.assign(feedback,{score:5,comment:''});feedbackSent.value=false}catch(error){message.error(apiMessage(error))}}
|
||||
async function next(){if(!active.value)return;submitting.value=true;try{active.value=await api.post<RunView>(`/runs/${active.value.run.id}/answer`,{node_key:active.value.node.node_key,answers:{...answers}});Object.keys(answers).forEach(k=>delete answers[k])}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
|
||||
async function load(){loading.value=true;try{sops.value=(await api.get<{items:PublishedSOP[]}>('/published-sops')).items;const runID=Number(route.query.run||0);if(runID>0)active.value=await api.get<RunView>(`/runs/${runID}`)}catch(error){message.error(apiMessage(error));await router.replace({query:{}})}finally{loading.value=false}}
|
||||
async function clearAnswers(){Object.keys(answers).forEach(k=>delete answers[k]);await nextTick();answerForm.value?.clearValidate()}
|
||||
async function start(sopID:number){try{active.value=await api.post<RunView>('/runs',{sop_id:sopID});await router.replace({query:{run:String(active.value.run.id)}});await clearAnswers();Object.assign(feedback,{score:5,comment:''});feedbackSent.value=false}catch(error){message.error(apiMessage(error))}}
|
||||
async function next(){if(!active.value)return;try{await answerForm.value?.validate()}catch{return}submitting.value=true;try{active.value=await api.post<RunView>(`/runs/${active.value.run.id}/answer`,{node_key:active.value.node.node_key,answers:{...answers}});await clearAnswers()}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
|
||||
function finishManually(){
|
||||
if(!active.value)return
|
||||
Modal.confirm({
|
||||
title:'结束本次执行?',
|
||||
content:'当前节点尚未提交的内容不会保存,执行记录将标记为“人工结束”。',
|
||||
okText:'确认结束',cancelText:'继续执行',okType:'danger',
|
||||
async onOk(){
|
||||
if(!active.value)return
|
||||
submitting.value=true
|
||||
try{active.value=await api.post<RunView>(`/runs/${active.value.run.id}/finish`,{result:'manual'});Object.keys(answers).forEach(k=>delete answers[k]);message.success('本次执行已结束')}
|
||||
catch(error){message.error(apiMessage(error));throw error}
|
||||
finally{submitting.value=false}
|
||||
},
|
||||
})
|
||||
}
|
||||
function inputFor(field:ScenarioField){return field.field_type}
|
||||
function validationNumber(field:ScenarioField,key:string){const value=field.validation?.[key];return typeof value==='number'?value:undefined}
|
||||
function fieldRequired(field:ScenarioField){return field.required||(['question','choice'].includes(active.value?.node.type||'')&¤tConfig.value.required===true)}
|
||||
function fieldRules(field:ScenarioField){
|
||||
const rules:any[]=[]
|
||||
if(fieldRequired(field))rules.push({required:true,message:`请填写${field.field_name}`,trigger:['change','blur']})
|
||||
if(['text','textarea'].includes(field.field_type)){
|
||||
const min=validationNumber(field,'min_length');const max=validationNumber(field,'max_length')
|
||||
if(min!==undefined)rules.push({min,message:`${field.field_name}不能少于 ${min} 个字符`,trigger:'blur'})
|
||||
if(max!==undefined)rules.push({max,message:`${field.field_name}不能超过 ${max} 个字符`,trigger:'blur'})
|
||||
}
|
||||
if(field.field_type==='number'){
|
||||
const min=validationNumber(field,'min');const max=validationNumber(field,'max')
|
||||
rules.push({type:'number',message:`${field.field_name}必须是数字`,trigger:'change'})
|
||||
if(min!==undefined)rules.push({type:'number',min,message:`${field.field_name}不能小于 ${min}`,trigger:'change'})
|
||||
if(max!==undefined)rules.push({type:'number',max,message:`${field.field_name}不能大于 ${max}`,trigger:'change'})
|
||||
}
|
||||
return rules
|
||||
}
|
||||
async function sendFeedback(){if(!active.value)return;submitting.value=true;try{await api.post(`/runs/${active.value.run.id}/feedback`,feedback);feedbackSent.value=true;message.success('反馈已提交')}catch(error){message.error(apiMessage(error))}finally{submitting.value=false}}
|
||||
function reset(){active.value=null;load()}
|
||||
async function reset(){active.value=null;await router.replace({query:{}});await load()}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
@@ -31,29 +69,35 @@ onMounted(load)
|
||||
<div v-else class="run-workspace">
|
||||
<aside class="run-context">
|
||||
<span class="run-label">RUN-{{ String(active.run.id).padStart(5,'0') }}</span>
|
||||
<h2>执行进行中</h2><p>客户回答会自动保存在当前执行记录中。</p>
|
||||
<h2>{{ active.run.status==='completed'?'执行已结束':'执行进行中' }}</h2><p>客户回答会自动保存在当前执行记录中。</p>
|
||||
<div class="context-meta"><span>当前节点</span><b>{{ active.node.title }}</b></div><div class="context-meta"><span>已采集字段</span><b>{{ Object.keys(active.run.answers || {}).length }}</b></div>
|
||||
<div class="privacy-note"><SafetyCertificateOutlined /><span>敏感信息请遵循企业数据规范</span></div>
|
||||
</aside>
|
||||
<main class="conversation surface">
|
||||
<div class="conversation-progress"><span :class="{done:active.run.status==='completed'}"></span><b>{{ active.run.status==='completed'?'流程已完成':'正在执行' }}</b></div>
|
||||
<div class="node-content"><small>{{ active.node.type.toUpperCase() }}</small><h1>{{ active.node.title }}</h1><blockquote v-if="active.node.content">{{ active.node.content }}</blockquote></div>
|
||||
<div v-if="active.run.status==='completed'" class="completed-state"><CheckCircleOutlined /><h3>{{ active.node.type==='escalate'?'已转交处理':'本次执行已完成' }}</h3><p>{{ active.node.content }}</p><div v-if="!feedbackSent" class="quick-feedback"><span>这套 SOP 是否清晰好用?</span><a-rate v-model:value="feedback.score"/><a-textarea v-model:value="feedback.comment" :rows="2" :maxlength="2000" placeholder="可填写需要改进的步骤"/><a-button type="primary" :loading="submitting" @click="sendFeedback">提交反馈</a-button></div><div v-else class="feedback-thanks">反馈已记录,将用于后续优化。</div><a-button @click="reset">执行另一套 SOP</a-button></div>
|
||||
<section v-if="active.node.knowledge" class="knowledge-snapshot">
|
||||
<div class="knowledge-head"><b>{{ active.node.knowledge.title }}</b><span>知识卡 V{{ active.node.knowledge.version }}</span></div>
|
||||
<div class="standard-copy"><small>标准话术</small><p>{{ active.node.knowledge.standard_copy }}</p></div>
|
||||
<div v-if="active.node.knowledge.forbidden_copy" class="knowledge-note forbidden"><small>禁用表达</small><p>{{ active.node.knowledge.forbidden_copy }}</p></div>
|
||||
<div v-if="active.node.knowledge.risk_note" class="knowledge-note risk"><small>风险提醒</small><p>{{ active.node.knowledge.risk_note }}</p></div>
|
||||
</section>
|
||||
<div v-if="active.run.status==='completed'" class="completed-state"><CheckCircleOutlined /><h3>{{ active.run.result==='manual'?'已人工结束':active.node.type==='escalate'?'已转交处理':'本次执行已完成' }}</h3><p>{{ active.run.result==='manual'?'本次执行由操作人员在当前节点结束。':active.node.content }}</p><div v-if="!feedbackSent" class="quick-feedback"><span>这套 SOP 是否清晰好用?</span><a-rate v-model:value="feedback.score"/><a-textarea v-model:value="feedback.comment" :rows="2" :maxlength="2000" placeholder="可填写需要改进的步骤"/><a-button type="primary" :loading="submitting" @click="sendFeedback">提交反馈</a-button></div><div v-else class="feedback-thanks">反馈已记录,将用于后续优化。</div><a-button @click="reset">执行另一套 SOP</a-button></div>
|
||||
<template v-else>
|
||||
<a-form layout="vertical" class="answer-form">
|
||||
<a-form-item v-for="field in currentFields" :key="field.id" :label="field.field_name" :required="field.required">
|
||||
<a-input v-if="inputFor(field)==='text'" v-model:value="answers[field.field_key]" />
|
||||
<a-textarea v-else-if="inputFor(field)==='textarea'" v-model:value="answers[field.field_key]" :rows="3" />
|
||||
<a-input-number v-else-if="inputFor(field)==='number'" v-model:value="answers[field.field_key]" style="width:100%" />
|
||||
<a-form ref="answerForm" :model="answers" layout="vertical" class="answer-form">
|
||||
<a-form-item v-for="field in currentFields" :key="field.id" :name="field.field_key" :label="field.field_name" :required="fieldRequired(field)" :rules="fieldRules(field)">
|
||||
<a-radio-group v-if="active.node.type==='choice'" v-model:value="answers[field.field_key]" class="choice-group"><a-radio-button v-for="option in currentConfig.options||[]" :key="option" :value="option">{{ option }}</a-radio-button></a-radio-group>
|
||||
<a-input v-else-if="inputFor(field)==='text'" v-model:value="answers[field.field_key]" :maxlength="validationNumber(field,'max_length')" show-count />
|
||||
<a-textarea v-else-if="inputFor(field)==='textarea'" v-model:value="answers[field.field_key]" :rows="3" :maxlength="validationNumber(field,'max_length')" show-count />
|
||||
<a-input-number v-else-if="inputFor(field)==='number'" v-model:value="answers[field.field_key]" :min="validationNumber(field,'min')" :max="validationNumber(field,'max')" style="width:100%" />
|
||||
<a-radio-group v-else-if="inputFor(field)==='boolean'" v-model:value="answers[field.field_key]"><a-radio :value="true">是</a-radio><a-radio :value="false">否</a-radio></a-radio-group>
|
||||
<a-select v-else-if="inputFor(field)==='select'" v-model:value="answers[field.field_key]" :options="(field.options||[]).map(value=>({value,label:value}))" />
|
||||
<a-select v-else-if="inputFor(field)==='multiselect'" v-model:value="answers[field.field_key]" mode="multiple" :options="(field.options||[]).map(value=>({value,label:value}))" />
|
||||
<a-date-picker v-else-if="inputFor(field)==='date'" v-model:value="answers[field.field_key]" style="width:100%" />
|
||||
<a-date-picker v-else-if="inputFor(field)==='date'" v-model:value="answers[field.field_key]" value-format="YYYY-MM-DD" style="width:100%" />
|
||||
<a-input v-else v-model:value="answers[field.field_key]" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="active.node.type==='choice'&¤tFields.length===0"><a-radio-group v-model:value="answers[currentConfig.field_key]" class="choice-group"><a-radio-button v-for="option in currentConfig.options||[]" :key="option" :value="option">{{ option }}</a-radio-button></a-radio-group></a-form-item>
|
||||
</a-form>
|
||||
<div class="run-actions"><span>{{ currentFields.length ? '填写后继续下一步' : '确认当前话术已完成' }}</span><a-button type="primary" size="large" :loading="submitting" @click="next">继续下一步</a-button></div>
|
||||
<div class="run-actions"><span>{{ currentFields.length ? '填写后继续下一步' : '确认当前话术已完成' }}</span><div><a-button danger :disabled="submitting" @click="finishManually"><StopOutlined />人工结束</a-button><a-button type="primary" size="large" :loading="submitting" @click="next">继续下一步</a-button></div></div>
|
||||
</template>
|
||||
</main>
|
||||
</div>
|
||||
@@ -62,6 +106,7 @@ onMounted(load)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.execution-shell{max-width:1220px}.sop-catalog{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.sop-entry{min-height:150px;display:grid;grid-template-columns:40px 1fr 42px;align-items:start;gap:14px;padding:22px;text-align:left;cursor:pointer}.sop-entry:hover{border-color:#9ac8b8;box-shadow:0 8px 24px rgba(32,40,37,.07)}.entry-seq{color:#8c9893;font-family:monospace;font-size:12px}.sop-entry small{color:var(--green);font-weight:650}.sop-entry h2{margin:8px 0 7px;font-family:"Noto Serif SC",serif;font-size:20px}.sop-entry p{margin:0;color:var(--muted);line-height:1.6}.play{width:38px;height:38px;display:grid;place-items:center;color:white;background:#202825;border-radius:4px;font-size:18px}.run-workspace{display:grid;grid-template-columns:270px minmax(0,1fr);gap:18px}.run-context{align-self:start;padding:24px;color:white;background:#202825;border-radius:6px;position:sticky;top:86px}.run-label{color:#72c9ab;font-size:11px;font-weight:700}.run-context h2{margin:12px 0 8px;font-family:"Noto Serif SC",serif}.run-context>p{margin:0 0 28px;color:#aebdb7;line-height:1.6}.context-meta{display:flex;align-items:center;justify-content:space-between;padding:13px 0;border-top:1px solid #3a4742}.context-meta span{color:#9eada7;font-size:12px}.privacy-note{display:flex;gap:8px;margin-top:28px;color:#889b93;font-size:11px}.conversation{min-height:590px;padding:30px 34px}.conversation-progress{display:flex;align-items:center;gap:8px;color:#66736d;font-size:12px}.conversation-progress span{width:8px;height:8px;border-radius:50%;background:#d08736;box-shadow:0 0 0 4px #faefe1}.conversation-progress span.done{background:var(--green);box-shadow:0 0 0 4px #e4f1ec}.node-content{padding:44px 0 26px}.node-content small{color:var(--green);font-size:10px;font-weight:800}.node-content h1{margin:8px 0 22px;font-family:"Noto Serif SC",serif;font-size:28px}.node-content blockquote{margin:0;padding:18px 20px;color:#29342f;background:#f2f6f4;border-left:3px solid var(--green);font-size:17px;line-height:1.8}.answer-form{max-width:680px}.choice-group{display:flex;flex-wrap:wrap}.run-actions{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:24px -34px -30px;padding:18px 34px;border-top:1px solid var(--line);color:var(--muted);font-size:12px}.completed-state{padding:40px 0;text-align:center}.completed-state>span{color:var(--green);font-size:50px}.completed-state h3{margin:14px 0 8px;font-family:"Noto Serif SC",serif;font-size:24px}.completed-state p{margin:0 0 24px;color:var(--muted)}.quick-feedback{max-width:620px;display:grid;grid-template-columns:180px 1fr auto;align-items:center;gap:12px;margin:0 auto 18px;padding:16px;text-align:left;background:#f5f8f6;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.quick-feedback>span{font-size:12px;font-weight:650}.quick-feedback :deep(.ant-rate){font-size:18px}.quick-feedback :deep(.ant-input){grid-column:1/3}.feedback-thanks{max-width:520px;margin:0 auto 18px;padding:12px;color:var(--green);background:#edf6f2;font-size:12px}
|
||||
.execution-shell{max-width:1220px}.sop-catalog{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.sop-entry{min-height:150px;display:grid;grid-template-columns:40px 1fr 42px;align-items:start;gap:14px;padding:22px;text-align:left;cursor:pointer}.sop-entry:hover{border-color:#9ac8b8;box-shadow:0 8px 24px rgba(32,40,37,.07)}.entry-seq{color:#8c9893;font-family:monospace;font-size:12px}.sop-entry small{color:var(--green);font-weight:650}.sop-entry h2{margin:8px 0 7px;font-family:"Noto Serif SC",serif;font-size:20px}.sop-entry p{margin:0;color:var(--muted);line-height:1.6}.play{width:38px;height:38px;display:grid;place-items:center;color:white;background:#202825;border-radius:4px;font-size:18px}.run-workspace{display:grid;grid-template-columns:270px minmax(0,1fr);gap:18px}.run-context{align-self:start;padding:24px;color:white;background:#202825;border-radius:6px;position:sticky;top:86px}.run-label{color:#72c9ab;font-size:11px;font-weight:700}.run-context h2{margin:12px 0 8px;font-family:"Noto Serif SC",serif}.run-context>p{margin:0 0 28px;color:#aebdb7;line-height:1.6}.context-meta{display:flex;align-items:center;justify-content:space-between;padding:13px 0;border-top:1px solid #3a4742}.context-meta span{color:#9eada7;font-size:12px}.privacy-note{display:flex;gap:8px;margin-top:28px;color:#889b93;font-size:11px}.conversation{min-height:590px;padding:30px 34px}.conversation-progress{display:flex;align-items:center;gap:8px;color:#66736d;font-size:12px}.conversation-progress span{width:8px;height:8px;border-radius:50%;background:#d08736;box-shadow:0 0 0 4px #faefe1}.conversation-progress span.done{background:var(--green);box-shadow:0 0 0 4px #e4f1ec}.node-content{padding:44px 0 26px}.node-content small{color:var(--green);font-size:10px;font-weight:800}.node-content h1{margin:8px 0 22px;font-family:"Noto Serif SC",serif;font-size:28px}.node-content blockquote{margin:0;padding:18px 20px;color:#29342f;background:#f2f6f4;border-left:3px solid var(--green);font-size:17px;line-height:1.8}.answer-form{max-width:680px}.choice-group{display:flex;flex-wrap:wrap}.run-actions{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:24px -34px -30px;padding:18px 34px;border-top:1px solid var(--line);color:var(--muted);font-size:12px}.run-actions>div{display:flex;align-items:center;gap:9px}.completed-state{padding:40px 0;text-align:center}.completed-state>span{color:var(--green);font-size:50px}.completed-state h3{margin:14px 0 8px;font-family:"Noto Serif SC",serif;font-size:24px}.completed-state p{margin:0 0 24px;color:var(--muted)}.quick-feedback{max-width:620px;display:grid;grid-template-columns:180px 1fr auto;align-items:center;gap:12px;margin:0 auto 18px;padding:16px;text-align:left;background:#f5f8f6;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.quick-feedback>span{font-size:12px;font-weight:650}.quick-feedback :deep(.ant-rate){font-size:18px}.quick-feedback :deep(.ant-input){grid-column:1/3}.feedback-thanks{max-width:520px;margin:0 auto 18px;padding:12px;color:var(--green);background:#edf6f2;font-size:12px}
|
||||
.knowledge-snapshot{margin:0 0 24px;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.knowledge-head{display:flex;align-items:center;justify-content:space-between;padding:12px 0}.knowledge-head span{color:var(--muted);font-size:11px}.standard-copy,.knowledge-note{padding:14px 16px;border-left:3px solid var(--green);background:#f2f7f5}.standard-copy small,.knowledge-note small{font-size:10px;font-weight:750}.standard-copy p,.knowledge-note p{margin:6px 0 0;line-height:1.7}.knowledge-note{margin-top:8px;border-left-color:#c4573f;background:#fbf3f0}.knowledge-note.risk{border-left-color:#bd7b24;background:#fbf6eb}
|
||||
@media(max-width:800px){.sop-catalog{grid-template-columns:1fr}.run-workspace{grid-template-columns:1fr}.run-context{position:static}.conversation{padding:22px}.run-actions{margin:24px -22px -22px;padding:16px 22px}.run-actions span{display:none}.quick-feedback{grid-template-columns:1fr}.quick-feedback :deep(.ant-input){grid-column:auto}}
|
||||
</style>
|
||||
|
||||
@@ -13,7 +13,7 @@ const fieldMap=computed(()=>new Map((detail.value?.fields||[]).map(field=>[field
|
||||
const answers=computed(()=>Object.entries(detail.value?.run.answers||{}))
|
||||
const canFeedback=computed(()=>auth.can('runs.feedback')&&detail.value?.run.status==='completed'&&!detail.value.feedback.some(item=>item.user_id===auth.user?.user_id))
|
||||
function resultText(v:string){return{finish:'正常结束',escalate:'转人工 / 转诊',manual:'人工结束'}[v]||v||'进行中'}
|
||||
function actionText(v:string){return{start:'开始执行',enter:'进入节点',answer:'提交回答'}[v]||v}
|
||||
function actionText(v:string){return{start:'开始执行',enter:'进入节点',answer:'提交回答',finish:'人工结束'}[v]||v}
|
||||
function valueText(value:any){if(value===true)return'是';if(value===false)return'否';if(Array.isArray(value))return value.join('、');if(value===null||value===undefined||value==='')return'-';return String(value)}
|
||||
function answerEntries(event:RunEvent){const values=event.payload?.answers||{};return Object.entries(values)}
|
||||
async function load(){loading.value=true;try{detail.value=await api.get<DetailResponse>(`/runs/${route.params.id}/detail`)}catch(error){message.error(apiMessage(error));router.replace('/runs')}finally{loading.value=false}}
|
||||
@@ -35,9 +35,10 @@ onMounted(load)
|
||||
<main class="surface timeline-panel">
|
||||
<div class="section-title"><span>执行轨迹</span><small>按实际发生顺序记录</small></div>
|
||||
<a-timeline class="run-timeline">
|
||||
<a-timeline-item v-for="event in detail.events" :key="event.id" :color="event.action==='answer'?'blue':event.node_type==='escalate'?'red':'green'">
|
||||
<a-timeline-item v-for="event in detail.events" :key="event.id" :color="event.action==='finish'||event.node_type==='escalate'?'red':event.action==='answer'?'blue':'green'">
|
||||
<div class="event-head"><b>{{ event.node_title||event.node_key }}</b><a-tag>{{ actionText(event.action) }}</a-tag><time>{{ new Date(event.created_at).toLocaleString('zh-CN') }}</time></div>
|
||||
<p v-if="event.action==='enter'&&event.node_content">{{ event.node_content }}</p>
|
||||
<div v-if="event.action==='enter'&&event.knowledge" class="event-knowledge"><div><b>{{ event.knowledge.title }}</b><small>V{{ event.knowledge.version }}</small></div><p>{{ event.knowledge.standard_copy }}</p><p v-if="event.knowledge.risk_note" class="risk-note">{{ event.knowledge.risk_note }}</p></div>
|
||||
<div v-if="answerEntries(event).length" class="event-answers"><span v-for="[key,value] in answerEntries(event)" :key="key"><small>{{ fieldMap.get(key)||key }}</small><b>{{ valueText(value) }}</b></span></div>
|
||||
</a-timeline-item>
|
||||
</a-timeline>
|
||||
@@ -62,4 +63,5 @@ onMounted(load)
|
||||
|
||||
<style scoped>
|
||||
.run-detail-page{max-width:1320px}.back-link{height:auto;margin:0 0 14px;padding:0}.detail-heading{display:flex;align-items:flex-end;justify-content:space-between;gap:24px;margin-bottom:20px}.run-code{color:var(--green);font-family:monospace;font-size:12px;font-weight:750}.detail-heading h1{margin:7px 0 5px;font-family:"Noto Serif SC",serif;font-size:28px}.detail-heading p{margin:0;color:var(--muted)}.result-block{min-width:210px;display:flex;align-items:center;gap:12px;padding:15px 17px;border-left:3px solid}.result-block>span:first-child{font-size:25px}.result-block>span:last-child{display:flex;flex-direction:column}.result-block small{font-size:10px}.result-block b{margin-top:3px}.result-block.success{color:#17664f;background:#eaf4f0;border-color:#25866a}.result-block.warning{color:#8d5b16;background:#fbf2e3;border-color:#c08026}.detail-layout{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:16px}.timeline-panel{min-height:560px;padding:22px 24px}.section-title{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:22px}.section-title span{font-weight:700}.section-title small{color:var(--muted);font-size:11px}.run-timeline{padding-top:5px}.event-head{display:flex;align-items:center;gap:9px;min-height:27px}.event-head time{margin-left:auto;color:#8b9691;font-size:11px}.run-timeline p{margin:8px 0 0;color:#56635d;line-height:1.65}.event-answers{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;margin-top:10px}.event-answers span{display:flex;justify-content:space-between;gap:10px;padding:8px 10px;background:#f4f7f5;border-left:2px solid #a6cbbd}.event-answers small{color:var(--muted)}.event-answers b{font-size:12px}.detail-aside{display:flex;flex-direction:column;gap:16px}.summary-panel,.answer-panel{padding:20px}.summary-panel dl{margin:0}.summary-panel dl>div{display:flex;justify-content:space-between;gap:15px;padding:10px 0;border-bottom:1px solid #edf0ee}.summary-panel dl>div:last-child{border-bottom:0}.summary-panel dt{color:var(--muted);font-size:12px}.summary-panel dd{margin:0;text-align:right;font-size:12px}.answer-list>div{display:flex;flex-direction:column;padding:10px 0;border-bottom:1px solid #edf0ee}.answer-list>div:last-child{border-bottom:0}.answer-list span{color:var(--muted);font-size:11px}.answer-list b{margin-top:4px;font-size:13px}.aside-empty{padding:24px 0;color:#929d98;text-align:center;font-size:12px}.feedback-band{margin-top:16px;padding:22px 24px}.feedback-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.feedback-list article{padding:14px 0;border-top:1px solid var(--line)}.feedback-list article>div{display:flex;align-items:center;justify-content:space-between}.feedback-list :deep(.ant-rate){font-size:15px}.feedback-list p{margin:9px 0;color:#54615b}.feedback-list time{color:#929d98;font-size:11px}.feedback-form{display:grid;grid-template-columns:220px minmax(0,1fr) auto;align-items:center;gap:14px;padding-top:16px;border-top:1px solid var(--line)}.feedback-form>div{display:flex;flex-direction:column;gap:6px}.feedback-form>div>span{font-size:12px;font-weight:650}.feedback-form :deep(.ant-rate){font-size:20px}@media(max-width:900px){.detail-layout{grid-template-columns:1fr}.detail-aside{display:grid;grid-template-columns:1fr 1fr}.feedback-form{grid-template-columns:1fr}.feedback-list{grid-template-columns:1fr}}@media(max-width:620px){.detail-heading{align-items:flex-start;flex-direction:column}.result-block{width:100%}.detail-aside{grid-template-columns:1fr}.timeline-panel{padding:18px}.event-head{align-items:flex-start;flex-wrap:wrap}.event-head time{width:100%;margin:0}.event-answers{grid-template-columns:1fr}}
|
||||
.event-knowledge{margin-top:10px;padding:12px 14px;background:#f2f7f5;border-left:3px solid var(--green)}.event-knowledge>div{display:flex;justify-content:space-between;gap:12px}.event-knowledge small{color:var(--muted)}.event-knowledge .risk-note{padding-top:8px;border-top:1px solid #dce9e4;color:#8d5b16}
|
||||
</style>
|
||||
|
||||
@@ -1,22 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { FilterOutlined, ReloadOutlined, RightOutlined } from '@ant-design/icons-vue'
|
||||
import { FilterOutlined, PlayCircleOutlined, ReloadOutlined, RightOutlined } from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api, apiMessage } from '@/api/client'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { SOPRun } from '@/types'
|
||||
|
||||
const router=useRouter()
|
||||
const auth=useAuthStore()
|
||||
const loading=ref(false);const items=ref<SOPRun[]>([]);const total=ref(0);const page=ref(1);const pageSize=ref(20)
|
||||
const filters=reactive({status:'',result:''})
|
||||
const columns=[{title:'执行编号',dataIndex:'id',width:130},{title:'SOP',dataIndex:'sop_name'},{title:'状态',dataIndex:'status',width:110},{title:'结果',dataIndex:'result',width:130},{title:'开始时间',dataIndex:'started_at',width:190},{title:'完成时间',dataIndex:'completed_at',width:190},{title:'',key:'action',width:54}]
|
||||
async function load(reset=false){if(reset)page.value=1;loading.value=true;try{const data=await api.get<{items:SOPRun[];total:number}>('/runs',{params:{page:page.value,page_size:pageSize.value,status:filters.status||undefined,result:filters.result||undefined}});items.value=data.items||[];total.value=data.total}catch(error){message.error(apiMessage(error))}finally{loading.value=false}}
|
||||
interface FilterOption{value:number;label:string}
|
||||
const filters=reactive({status:'',result:'',sop_id:undefined as number|undefined,operator_id:undefined as number|undefined})
|
||||
const dateRange=ref<any[]>([])
|
||||
const filterOptions=reactive<{sops:FilterOption[];operators:FilterOption[]}>({sops:[],operators:[]})
|
||||
const columns=[{title:'执行编号',dataIndex:'id',width:130},{title:'SOP / 场景',key:'sop',width:260},{title:'执行人',dataIndex:'operator_name',width:130},{title:'状态',dataIndex:'status',width:100},{title:'结果',dataIndex:'result',width:130},{title:'开始时间',dataIndex:'started_at',width:180},{title:'完成时间',dataIndex:'completed_at',width:180},{title:'',key:'action',width:54}]
|
||||
function queryParams(){return{page:page.value,page_size:pageSize.value,status:filters.status||undefined,result:filters.result||undefined,sop_id:filters.sop_id,operator_id:filters.operator_id,started_from:dateRange.value?.[0]?.startOf('day').toISOString(),started_to:dateRange.value?.[1]?.endOf('day').toISOString()}}
|
||||
async function load(reset=false){if(reset)page.value=1;loading.value=true;try{const data=await api.get<{items:SOPRun[];total:number}>('/runs',{params:queryParams()});items.value=data.items||[];total.value=data.total}catch(error){message.error(apiMessage(error))}finally{loading.value=false}}
|
||||
async function loadOptions(){try{const data=await api.get<{sops:FilterOption[];operators:FilterOption[]}>('/runs/options');filterOptions.sops=data.sops||[];filterOptions.operators=data.operators||[]}catch(error){message.error(apiMessage(error))}}
|
||||
function resetFilters(){Object.assign(filters,{status:'',result:'',sop_id:undefined,operator_id:undefined});dateRange.value=[];load(true)}
|
||||
function statusText(v:string){return v==='completed'?'已完成':'进行中'}
|
||||
function resultText(v:string){return{finish:'正常结束',escalate:'转人工 / 转诊',manual:'人工结束'}[v]||v||'-'}
|
||||
function changePage(next:number,size:number){page.value=next;pageSize.value=size;load()}
|
||||
function rowProps(record:SOPRun){return{onClick:()=>router.push(`/runs/${record.id}`)}}
|
||||
function canContinue(record:SOPRun){return record.status==='running'&&auth.can('sop.execute')&&(record.operator_id===auth.user?.user_id||auth.can('*'))}
|
||||
function continueRun(record:SOPRun){router.push({path:'/execute',query:{run:String(record.id)}})}
|
||||
function tableChange(pager:{current?:number;pageSize?:number}){changePage(pager.current||1,pager.pageSize||20)}
|
||||
onMounted(load)
|
||||
onMounted(()=>{load();loadOptions()})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -24,11 +34,11 @@ onMounted(load)
|
||||
<div class="page-heading"><div><h1>执行记录</h1><p>按结果筛选实际执行,进入详情复盘每一步回答和分流。</p></div></div>
|
||||
<section class="surface">
|
||||
<div class="history-toolbar">
|
||||
<div class="filter-set"><FilterOutlined /><a-select v-model:value="filters.status" allow-clear placeholder="全部状态" :options="[{value:'running',label:'进行中'},{value:'completed',label:'已完成'}]" @change="load(true)"/><a-select v-model:value="filters.result" allow-clear placeholder="全部结果" :options="[{value:'finish',label:'正常结束'},{value:'escalate',label:'转人工 / 转诊'},{value:'manual',label:'人工结束'}]" @change="load(true)"/></div>
|
||||
<div class="filter-set"><FilterOutlined /><a-select v-model:value="filters.status" allow-clear placeholder="全部状态" :options="[{value:'running',label:'进行中'},{value:'completed',label:'已完成'}]" @change="load(true)"/><a-select v-model:value="filters.result" allow-clear placeholder="全部结果" :options="[{value:'finish',label:'正常结束'},{value:'escalate',label:'转人工 / 转诊'},{value:'manual',label:'人工结束'}]" @change="load(true)"/><a-select v-model:value="filters.sop_id" allow-clear show-search option-filter-prop="label" placeholder="全部 SOP" :options="filterOptions.sops" @change="load(true)"/><a-select v-model:value="filters.operator_id" allow-clear show-search option-filter-prop="label" placeholder="全部执行人" :options="filterOptions.operators" @change="load(true)"/><a-range-picker v-model:value="dateRange" :allow-clear="true" @change="load(true)"/><a-button type="link" @click="resetFilters">重置</a-button></div>
|
||||
<a-button type="text" aria-label="刷新执行记录" @click="load()"><ReloadOutlined /></a-button>
|
||||
</div>
|
||||
<a-table row-key="id" :loading="loading" :data-source="items" :columns="columns" :pagination="{current:page,pageSize,total,showSizeChanger:true,showTotal:(value:number)=>`共 ${value} 条`}" :scroll="{x:980}" :custom-row="rowProps" @change="tableChange">
|
||||
<template #bodyCell="{column,record}"><template v-if="column.dataIndex==='id'"><code>RUN-{{ String(record.id).padStart(5,'0') }}</code></template><template v-else-if="column.dataIndex==='status'"><a-tag :color="record.status==='completed'?'green':'orange'">{{ statusText(record.status) }}</a-tag></template><template v-else-if="column.dataIndex==='result'">{{ resultText(record.result) }}</template><template v-else-if="column.dataIndex==='started_at'||column.dataIndex==='completed_at'">{{ record[column.dataIndex]?new Date(record[column.dataIndex]).toLocaleString('zh-CN'):'-' }}</template><template v-else-if="column.key==='action'"><RightOutlined class="row-arrow" /></template></template>
|
||||
<template #bodyCell="{column,record}"><template v-if="column.dataIndex==='id'"><code>RUN-{{ String(record.id).padStart(5,'0') }}</code></template><template v-else-if="column.key==='sop'"><div class="run-sop"><b>{{ record.sop_name }}</b><small>{{ record.scenario_name }} · V{{ record.version }}</small></div></template><template v-else-if="column.dataIndex==='status'"><a-tag :color="record.status==='completed'?'green':'orange'">{{ statusText(record.status) }}</a-tag></template><template v-else-if="column.dataIndex==='result'">{{ resultText(record.result) }}</template><template v-else-if="column.dataIndex==='started_at'||column.dataIndex==='completed_at'">{{ record[column.dataIndex]?new Date(record[column.dataIndex]).toLocaleString('zh-CN'):'-' }}</template><template v-else-if="column.key==='action'"><a-tooltip v-if="canContinue(record)" title="继续执行"><a-button type="text" aria-label="继续执行" @click.stop="continueRun(record)"><PlayCircleOutlined /></a-button></a-tooltip><RightOutlined v-else class="row-arrow" /></template></template>
|
||||
<template #emptyText><div class="empty-copy">当前筛选条件下没有执行记录。</div></template>
|
||||
</a-table>
|
||||
</section>
|
||||
@@ -36,5 +46,5 @@ onMounted(load)
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.history-toolbar{min-height:58px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px;border-bottom:1px solid var(--line)}.filter-set{display:flex;align-items:center;gap:9px;color:#7a8781}.filter-set :deep(.ant-select){width:160px}.surface :deep(.ant-table-row){cursor:pointer}.surface :deep(.ant-table-row:hover) .row-arrow{color:var(--green);transform:translateX(2px)}code{padding:2px 5px;color:#17654f;background:#eef5f2;border-radius:3px}.row-arrow{color:#9ba6a1;transition:all .16s ease}@media(max-width:600px){.history-toolbar{align-items:flex-start}.filter-set{flex-wrap:wrap}.filter-set :deep(.ant-select){width:138px}}
|
||||
.history-toolbar{min-height:58px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px;border-bottom:1px solid var(--line)}.filter-set{display:flex;align-items:center;flex-wrap:wrap;gap:9px;color:#7a8781}.filter-set :deep(.ant-select){width:150px}.filter-set :deep(.ant-picker){width:240px}.run-sop{display:flex;flex-direction:column}.run-sop small{margin-top:4px;color:var(--muted)}.surface :deep(.ant-table-row){cursor:pointer}.surface :deep(.ant-table-row:hover) .row-arrow{color:var(--green);transform:translateX(2px)}code{padding:2px 5px;color:#17654f;background:#eef5f2;border-radius:3px}.row-arrow{color:#9ba6a1;transition:all .16s ease}@media(max-width:700px){.history-toolbar{align-items:flex-start}.filter-set{width:100%}.filter-set :deep(.ant-select){width:calc(50% - 18px)}.filter-set :deep(.ant-picker){width:100%}}
|
||||
</style>
|
||||
|
||||
@@ -70,6 +70,7 @@ function removeEdge(edge:SOPEdge){edges.value=edges.value.filter(item=>item!==ed
|
||||
function addConditionRule(){edgeDraft.rules.push(newConditionRule())}
|
||||
function removeConditionRule(index:number){if(edgeDraft.rules.length>1)edgeDraft.rules.splice(index,1)}
|
||||
function parseValue(value:string){if(value==='true')return true;if(value==='false')return false;if(value!==''&&!Number.isNaN(Number(value)))return Number(value);return value}
|
||||
function knowledgeCardChanged(){if(selected.value)delete selected.value.config.knowledge_card_version_id}
|
||||
function conditionText(condition:Record<string,any>):string{if(!condition||Object.keys(condition).length===0)return '默认路径';if(Array.isArray(condition.any))return `任一:${condition.any.map(conditionText).join(';')}`;if(Array.isArray(condition.all))return `全部:${condition.all.map(conditionText).join(';')}`;if(!condition.field)return '未识别条件';const names:Record<string,string>={equals:'等于',not_equals:'不等于',contains:'包含',greater_than:'大于',less_than:'小于',in:'属于列表',exists:'已填写',not_exists:'未填写'};const fieldName=fields.value.find(field=>field.field_key===condition.field)?.field_name||condition.field;const value=Array.isArray(condition.value)?condition.value.join('、'):(condition.value??'');return `${fieldName} ${names[condition.operator]||condition.operator} ${value}`}
|
||||
async function save():Promise<boolean>{saving.value=true;try{await api.put(`/sops/${id}/draft`,{start_node_key:version.value?.start_node_key||'start',nodes:nodes.value.map(({node_key,type,title,content,config,position_x,position_y})=>({node_key,type,title,content,config,position_x,position_y})),edges:edges.value.map(({source_node_key,target_node_key,condition,priority})=>({source_node_key,target_node_key,condition,priority}))});message.success('草稿已保存');await load();return true}catch(error){message.error(apiMessage(error));return false}finally{saving.value=false}}
|
||||
async function validate(){try{const suffix=route.query.version?`?version=${encodeURIComponent(String(route.query.version))}`:'';const data=await api.post<{valid:boolean;problems:string[]}>(`/sops/${id}/validate${suffix}`);data.valid?message.success('流程校验通过'):Modal.warning({title:'流程还不能发布',content:data.problems.join(';')})}catch(error){message.error(apiMessage(error))}}
|
||||
@@ -105,7 +106,7 @@ onMounted(load)
|
||||
<template v-if="['question','choice'].includes(selected.type)"><div class="property-grid"><a-form-item label="写入场景字段"><a-select v-model:value="selected.config.field_key" :disabled="!canEdit" show-search option-filter-prop="label" placeholder="选择需要采集的字段" :options="fields.map(field=>({value:field.field_key,label:`${field.field_name} (${field.field_key})`}))" /></a-form-item><a-form-item label="是否必填"><a-switch v-model:checked="selected.config.required" :disabled="!canEdit" /></a-form-item></div></template>
|
||||
<a-form-item v-if="selected.type==='form'" label="表单采集字段"><a-select v-model:value="selected.config.field_keys" mode="multiple" :disabled="!canEdit" show-search option-filter-prop="label" placeholder="选择本步骤需要采集的字段" :options="fields.map(field=>({value:field.field_key,label:`${field.field_name} (${field.field_key})`}))" /></a-form-item>
|
||||
<a-form-item v-if="selected.type==='choice'" label="可选项(每行一个)"><a-textarea :value="(selected.config.options||[]).join('\n')" :disabled="!canEdit" :rows="4" @change="selected.config.options=($event.target as HTMLTextAreaElement).value.split('\n').filter(Boolean)" /></a-form-item>
|
||||
<a-form-item v-if="selected.type==='knowledge'" label="关联知识卡"><a-select v-model:value="selected.config.knowledge_card_id" :disabled="!canEdit" placeholder="选择已发布知识卡" :options="knowledgeCards.map(card=>({value:card.id,label:card.title}))" /></a-form-item>
|
||||
<a-form-item v-if="selected.type==='knowledge'" label="关联知识卡"><a-select v-model:value="selected.config.knowledge_card_id" :disabled="!canEdit" placeholder="选择已发布知识卡" :options="knowledgeCards.map(card=>({value:card.id,label:card.title}))" @change="knowledgeCardChanged" /><div v-if="selected.config.knowledge_card_version_id" class="snapshot-note">已发布快照:知识卡版本 ID {{ selected.config.knowledge_card_version_id }}</div></a-form-item>
|
||||
</a-form>
|
||||
<div class="transition-head"><div><b>下一步路径</b><span>条件路径优先匹配,默认路径始终排在最后</span></div></div>
|
||||
<div class="transition-list"><div v-for="edge in outgoing(selectedKey)" :key="`${edge.source_node_key}-${edge.target_node_key}-${edge.priority}`" class="transition-row"><span class="route-line"></span><span class="route-target">{{ nodes.find(n=>n.node_key===edge.target_node_key)?.title || edge.target_node_key }}</span><a-tag>{{ conditionText(edge.condition) }}</a-tag><a-button v-if="canEdit" type="text" danger aria-label="删除路径" @click="removeEdge(edge)"><DeleteOutlined /></a-button></div><div v-if="!outgoing(selectedKey).length" class="route-empty">还没有下一步路径</div></div>
|
||||
@@ -135,5 +136,6 @@ onMounted(load)
|
||||
|
||||
<style scoped>
|
||||
.editor-page{min-height:calc(100vh - 62px);background:#eef1ef}.editor-header{min-height:66px;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:10px 24px;background:white;border-bottom:1px solid var(--line);position:sticky;top:62px;z-index:10}.editor-title{display:flex;align-items:center;gap:8px}.editor-title>div{display:flex;flex-direction:column}.editor-title b{font-size:15px}.editor-title span{margin-top:3px;color:var(--muted);font-size:11px}.page-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px}.editor-workbench{display:grid;grid-template-columns:280px minmax(0,1fr);gap:16px;max-width:1500px;margin:0 auto;padding:18px}.node-rail{align-self:start;overflow:hidden;position:sticky;top:146px}.rail-title{height:50px;display:flex;align-items:center;justify-content:space-between;padding:0 14px;border-bottom:1px solid var(--line);font-weight:650}.node-list{padding:8px}.node-list button{width:100%;min-height:58px;display:grid;grid-template-columns:34px 1fr auto;align-items:center;gap:9px;padding:7px 8px;text-align:left;background:transparent;border:1px solid transparent;border-radius:4px;cursor:pointer}.node-list button:hover{background:#f5f8f6}.node-list button.active{background:#edf6f2;border-color:#b9d8cc}.node-index{color:#88958f;font-family:monospace;font-size:11px}.node-meta{min-width:0;display:flex;flex-direction:column}.node-meta b{overflow:hidden;font-size:13px;text-overflow:ellipsis;white-space:nowrap}.node-meta small{margin-top:3px;color:var(--muted);font-size:11px}.node-move{display:flex;gap:4px;color:#87938e}.node-move>*:hover{color:var(--green)}.node-editor{align-self:start;min-height:600px;padding:24px}.node-editor-head{display:flex;align-items:flex-start;justify-content:space-between;padding-bottom:18px;border-bottom:1px solid var(--line)}.node-editor-head h2{margin:6px 0 0;font-family:"Noto Serif SC",serif;font-size:22px}.node-type-mark{color:var(--green);font-size:10px;font-weight:750}.property-form{padding-top:22px}.property-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}.transition-head{display:flex;justify-content:space-between;margin:8px -24px 0;padding:18px 24px 10px;border-top:1px solid var(--line)}.transition-head>div{display:flex;flex-direction:column}.transition-head span{margin-top:4px;color:var(--muted);font-size:11px}.transition-row{min-height:48px;display:grid;grid-template-columns:20px minmax(120px,1fr) auto 36px;align-items:center;gap:10px;border-bottom:1px solid #edf0ee}.route-line{width:16px;height:1px;background:var(--green)}.route-target{font-weight:600}.route-empty{padding:20px 0;color:var(--muted);font-size:12px}.edge-builder{padding-top:16px}.edge-main{display:grid;grid-template-columns:minmax(220px,1fr) auto auto;align-items:center;gap:10px}.condition-toggle{display:flex;align-items:center;gap:7px;color:var(--muted);font-size:12px}.condition-builder{margin-top:12px;padding:14px;background:#f7f9f8;border:1px solid var(--line);border-radius:4px}.condition-mode{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;color:var(--muted);font-size:12px}.condition-rule{display:grid;grid-template-columns:1.2fr 130px 1fr 34px;gap:8px;margin-top:8px}.editor-empty{min-height:400px;display:grid;place-items:center}.version-row{display:grid;grid-template-columns:54px 1fr auto;align-items:center;gap:12px;padding:16px 8px;border-bottom:1px solid var(--line)}.version-row.current{background:#f1f7f4}.version-number{font-weight:750;color:#245f4d}.version-copy>div{display:flex;align-items:center;gap:6px}.version-copy time{display:block;margin-top:5px;color:var(--muted);font-size:11px}.version-actions{display:flex;gap:6px}
|
||||
.snapshot-note{margin-top:6px;color:var(--muted);font-size:11px}
|
||||
@media(max-width:1050px){.editor-workbench{grid-template-columns:230px minmax(0,1fr)}.condition-rule{grid-template-columns:1fr 120px 1fr 34px}}@media(max-width:760px){.editor-header{top:62px;align-items:flex-start;flex-direction:column;padding:12px}.page-actions{justify-content:flex-start}.editor-workbench{grid-template-columns:1fr;padding:10px}.node-rail{position:static}.property-grid{grid-template-columns:1fr}.node-editor{padding:16px}.transition-head{margin:8px -16px 0;padding:16px}.edge-main,.condition-rule{grid-template-columns:1fr}.condition-rule .ant-btn{justify-self:end}.version-row{grid-template-columns:46px 1fr}.version-actions{grid-column:2}}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user