feat: add exploration scale list, questions, and scoring
Seed communication-style scale, expose /api/v1/scales APIs, and wire Explore/Scale H5 pages for the P1 探索测试 path. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -18,6 +18,7 @@ import (
|
|||||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/profile"
|
"github.com/yuxingu/digital-psychology/apps/api/internal/service/profile"
|
||||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/relation"
|
"github.com/yuxingu/digital-psychology/apps/api/internal/service/relation"
|
||||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/report"
|
"github.com/yuxingu/digital-psychology/apps/api/internal/service/report"
|
||||||
|
"github.com/yuxingu/digital-psychology/apps/api/internal/service/scale"
|
||||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -54,6 +55,7 @@ func main() {
|
|||||||
profileSvc := &profile.Service{Repo: profileRepo}
|
profileSvc := &profile.Service{Repo: profileRepo}
|
||||||
reportSvc := &report.Service{Profiles: profileRepo, Reports: reportRepo}
|
reportSvc := &report.Service{Profiles: profileRepo, Reports: reportRepo}
|
||||||
relationSvc := &relation.Service{Profiles: profileRepo, Reports: reportRepo, Relations: relationRepo}
|
relationSvc := &relation.Service{Profiles: profileRepo, Reports: reportRepo, Relations: relationRepo}
|
||||||
|
scaleSvc := &scale.Service{Repo: &repository.ScaleRepo{Pool: pool}, Profiles: profileRepo}
|
||||||
|
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(gin.Recovery(), gin.Logger(), middleware.RequestID())
|
r.Use(gin.Recovery(), gin.Logger(), middleware.RequestID())
|
||||||
@@ -73,6 +75,7 @@ func main() {
|
|||||||
(&handler.ProfileHandler{Svc: profileSvc}).Register(authed)
|
(&handler.ProfileHandler{Svc: profileSvc}).Register(authed)
|
||||||
(&handler.ReportHandler{Svc: reportSvc}).Register(authed)
|
(&handler.ReportHandler{Svc: reportSvc}).Register(authed)
|
||||||
(&handler.RelationHandler{Svc: relationSvc}).Register(authed)
|
(&handler.RelationHandler{Svc: relationSvc}).Register(authed)
|
||||||
|
(&handler.ScaleHandler{Svc: scaleSvc}).Register(authed)
|
||||||
|
|
||||||
log.Printf("yuxingu api listening on %s env=%s", cfg.HTTPAddr, cfg.AppEnv)
|
log.Printf("yuxingu api listening on %s env=%s", cfg.HTTPAddr, cfg.AppEnv)
|
||||||
if err := r.Run(cfg.HTTPAddr); err != nil {
|
if err := r.Run(cfg.HTTPAddr); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||||
|
"github.com/yuxingu/digital-psychology/apps/api/internal/service/scale"
|
||||||
|
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ScaleHandler exposes 探索测试 APIs.
|
||||||
|
type ScaleHandler struct {
|
||||||
|
Svc *scale.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register mounts routes.
|
||||||
|
func (h *ScaleHandler) Register(rg *gin.RouterGroup) {
|
||||||
|
rg.GET("/scales", h.List)
|
||||||
|
rg.GET("/scales/:slug", h.Get)
|
||||||
|
rg.POST("/scales/:slug/result", h.Submit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List handles GET /scales.
|
||||||
|
func (h *ScaleHandler) List(c *gin.Context) {
|
||||||
|
items, err := h.Svc.List(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
response.Fail(c, http.StatusInternalServerError, 50003, "list failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get handles GET /scales/:slug.
|
||||||
|
func (h *ScaleHandler) Get(c *gin.Context) {
|
||||||
|
d, err := h.Svc.Get(c.Request.Context(), c.Param("slug"))
|
||||||
|
if err != nil {
|
||||||
|
response.Fail(c, http.StatusNotFound, 40402, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, d)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit handles POST /scales/:slug/result.
|
||||||
|
func (h *ScaleHandler) Submit(c *gin.Context) {
|
||||||
|
userID, ok := middleware.UserIDFromContext(c)
|
||||||
|
if !ok {
|
||||||
|
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
ProfileID string `json:"profile_id" binding:"required"`
|
||||||
|
Answers map[string]string `json:"answers" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pid, err := uuid.Parse(req.ProfileID)
|
||||||
|
if err != nil {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 10000, "invalid profile_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := h.Svc.Submit(c.Request.Context(), userID, c.Param("slug"), scale.SubmitInput{
|
||||||
|
ProfileID: pid, Answers: req.Answers,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 30006, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ScaleListItem is a published 探索测试.
|
||||||
|
type ScaleListItem struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScaleQuestion is one item in a scale.
|
||||||
|
type ScaleQuestion struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Sort int `json:"sort"`
|
||||||
|
Body json.RawMessage `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScaleDetail is scale + questions.
|
||||||
|
type ScaleDetail struct {
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Questions []ScaleQuestion `json:"questions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScaleResultRow stored result.
|
||||||
|
type ScaleResultRow struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ScaleSlug string `json:"scale_slug"`
|
||||||
|
Result json.RawMessage `json:"result"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScaleRepo loads scales and results.
|
||||||
|
type ScaleRepo struct {
|
||||||
|
Pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPublished returns published scales.
|
||||||
|
func (r *ScaleRepo) ListPublished(ctx context.Context) ([]ScaleListItem, error) {
|
||||||
|
rows, err := r.Pool.Query(ctx, `
|
||||||
|
SELECT slug, title, description FROM scales
|
||||||
|
WHERE status='published' AND deleted_at IS NULL ORDER BY created_at`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []ScaleListItem
|
||||||
|
for rows.Next() {
|
||||||
|
var it ScaleListItem
|
||||||
|
if err := rows.Scan(&it.Slug, &it.Title, &it.Description); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, it)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBySlug loads scale with questions.
|
||||||
|
func (r *ScaleRepo) GetBySlug(ctx context.Context, slug string) (*ScaleDetail, error) {
|
||||||
|
d := &ScaleDetail{Slug: slug}
|
||||||
|
var scaleID uuid.UUID
|
||||||
|
err := r.Pool.QueryRow(ctx, `
|
||||||
|
SELECT id, title, description FROM scales
|
||||||
|
WHERE slug=$1 AND deleted_at IS NULL`, slug,
|
||||||
|
).Scan(&scaleID, &d.Title, &d.Description)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := r.Pool.Query(ctx, `
|
||||||
|
SELECT id, sort, body FROM scale_questions
|
||||||
|
WHERE scale_id=$1 AND deleted_at IS NULL ORDER BY sort`, scaleID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var q ScaleQuestion
|
||||||
|
if err := rows.Scan(&q.ID, &q.Sort, &q.Body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
d.Questions = append(d.Questions, q)
|
||||||
|
}
|
||||||
|
return d, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveResult stores scoring output.
|
||||||
|
func (r *ScaleRepo) SaveResult(ctx context.Context, userID, scaleID, profileID uuid.UUID, answers, result json.RawMessage) (uuid.UUID, error) {
|
||||||
|
var id uuid.UUID
|
||||||
|
err := r.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO scale_results(user_id, scale_id, profile_id, answers, result)
|
||||||
|
VALUES ($1,$2,$3,$4,$5) RETURNING id`,
|
||||||
|
userID, scaleID, profileID, answers, result,
|
||||||
|
).Scan(&id)
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScaleIDBySlug resolves id.
|
||||||
|
func (r *ScaleRepo) ScaleIDBySlug(ctx context.Context, slug string) (uuid.UUID, error) {
|
||||||
|
var id uuid.UUID
|
||||||
|
err := r.Pool.QueryRow(ctx, `
|
||||||
|
SELECT id FROM scales WHERE slug=$1 AND deleted_at IS NULL`, slug).Scan(&id)
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package scale
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service serves 探索测试.
|
||||||
|
type Service struct {
|
||||||
|
Repo *repository.ScaleRepo
|
||||||
|
Profiles *repository.ProfileRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns published scales.
|
||||||
|
func (s *Service) List(ctx context.Context) ([]repository.ScaleListItem, error) {
|
||||||
|
return s.Repo.ListPublished(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns scale detail.
|
||||||
|
func (s *Service) Get(ctx context.Context, slug string) (*repository.ScaleDetail, error) {
|
||||||
|
d, err := s.Repo.GetBySlug(ctx, slug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("scale not found")
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitInput answers map question_id -> option key.
|
||||||
|
type SubmitInput struct {
|
||||||
|
ProfileID uuid.UUID
|
||||||
|
Answers map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitResult is exploration result.
|
||||||
|
type SubmitResult struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Result map[string]interface{} `json:"result"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit scores a simple majority style.
|
||||||
|
func (s *Service) Submit(ctx context.Context, userID uuid.UUID, slug string, in SubmitInput) (*SubmitResult, error) {
|
||||||
|
if _, err := s.Profiles.GetForUser(ctx, userID, in.ProfileID); err != nil {
|
||||||
|
return nil, errors.New("profile not found")
|
||||||
|
}
|
||||||
|
scaleID, err := s.Repo.ScaleIDBySlug(ctx, slug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("scale not found")
|
||||||
|
}
|
||||||
|
counts := map[string]int{}
|
||||||
|
for _, v := range in.Answers {
|
||||||
|
counts[v]++
|
||||||
|
}
|
||||||
|
best, bestN := "A", -1
|
||||||
|
for k, n := range counts {
|
||||||
|
if n > bestN {
|
||||||
|
best, bestN = k, n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
label := map[string]string{
|
||||||
|
"A": "理性澄清型",
|
||||||
|
"B": "感受连接型",
|
||||||
|
"C": "节奏尊重型",
|
||||||
|
}[best]
|
||||||
|
if label == "" {
|
||||||
|
label = "平衡探索型"
|
||||||
|
}
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"title": "探索结果",
|
||||||
|
"style_key": best,
|
||||||
|
"label": label,
|
||||||
|
"summary": "这是你当前沟通偏好的探索结果,可用于自我了解与关系理解,不是固定标签。",
|
||||||
|
"share_line": "我的沟通方式:" + label,
|
||||||
|
}
|
||||||
|
ansJSON, _ := json.Marshal(in.Answers)
|
||||||
|
resJSON, _ := json.Marshal(result)
|
||||||
|
id, err := s.Repo.SaveResult(ctx, userID, scaleID, in.ProfileID, ansJSON, resJSON)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &SubmitResult{ID: id, Result: result}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
DROP TABLE IF EXISTS scale_results;
|
||||||
|
DROP TABLE IF EXISTS scale_questions;
|
||||||
|
DROP TABLE IF EXISTS scales;
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS scales (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
slug varchar(64) NOT NULL UNIQUE,
|
||||||
|
title varchar(128) NOT NULL,
|
||||||
|
description varchar(512) NOT NULL DEFAULT '',
|
||||||
|
status varchar(32) NOT NULL DEFAULT 'published',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS scale_questions (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
scale_id uuid NOT NULL REFERENCES scales(id),
|
||||||
|
sort int NOT NULL DEFAULT 0,
|
||||||
|
body jsonb NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scale_questions_scale_id ON scale_questions(scale_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS scale_results (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id),
|
||||||
|
scale_id uuid NOT NULL REFERENCES scales(id),
|
||||||
|
profile_id uuid NOT NULL REFERENCES profiles(id),
|
||||||
|
answers jsonb NOT NULL DEFAULT '{}',
|
||||||
|
result jsonb NOT NULL DEFAULT '{}',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scale_results_user_id ON scale_results(user_id);
|
||||||
|
|
||||||
|
-- Seed one 探索测试 (communication style)
|
||||||
|
INSERT INTO scales (id, slug, title, description, status)
|
||||||
|
VALUES (
|
||||||
|
'11111111-1111-1111-1111-111111111111',
|
||||||
|
'communication-style',
|
||||||
|
'沟通方式探索',
|
||||||
|
'了解你在表达与倾听时的偏好,生成探索结果标签。',
|
||||||
|
'published'
|
||||||
|
) ON CONFLICT (slug) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO scale_questions (scale_id, sort, body) VALUES
|
||||||
|
(
|
||||||
|
'11111111-1111-1111-1111-111111111111', 1,
|
||||||
|
'{"prompt":"意见不合时,你更常怎么做?","options":[{"key":"A","text":"先讲清事实与逻辑"},{"key":"B","text":"先照顾彼此的情绪"},{"key":"C","text":"先暂停,稍后再谈"}]}'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'11111111-1111-1111-1111-111111111111', 2,
|
||||||
|
'{"prompt":"朋友找你商量事情,你更倾向于?","options":[{"key":"A","text":"帮对方拆解方案"},{"key":"B","text":"先认真听完感受"},{"key":"C","text":"给对方安静空间"}]}'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'11111111-1111-1111-1111-111111111111', 3,
|
||||||
|
'{"prompt":"你希望对方如何回应你?","options":[{"key":"A","text":"直接、清楚、可执行"},{"key":"B","text":"温暖、肯定、有共鸣"},{"key":"C","text":"尊重节奏、不急着下结论"}]}'
|
||||||
|
);
|
||||||
@@ -1,20 +1,42 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="page">
|
<main class="page">
|
||||||
<h1>探索</h1>
|
<h1>探索</h1>
|
||||||
<p class="sub">性格探索 · 人格测评 · 关系理解 · 个人画像</p>
|
<p class="sub">性格探索 · 探索测试 · 关系理解 · 个人画像</p>
|
||||||
|
<p v-if="error" class="err">{{ error }}</p>
|
||||||
<ul class="list">
|
<ul class="list">
|
||||||
<li><router-link to="/portrait">性格探索 / 个人画像</router-link></li>
|
<li><router-link to="/portrait">性格探索 / 个人画像</router-link></li>
|
||||||
<li>人格测评库(对接 /api/v1/scales)</li>
|
|
||||||
<li><router-link to="/relation">关系理解</router-link></li>
|
<li><router-link to="/relation">关系理解</router-link></li>
|
||||||
<li>身心探索(第二阶段)</li>
|
<li v-for="s in scales" :key="s.slug">
|
||||||
|
<router-link :to="`/scales/${s.slug}`">{{ s.title }}</router-link>
|
||||||
|
<span class="desc">{{ s.description }}</span>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
|
||||||
|
const scales = ref<{ slug: string; title: string; description: string }[]>([])
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.listScales()
|
||||||
|
scales.value = res.items || []
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '加载失败'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page{padding:24px 16px}
|
.page{padding:24px 16px}
|
||||||
h1{font-size:22px}
|
h1{font-size:22px}
|
||||||
.sub{color:var(--yxg-sub);font-size:13px;margin:6px 0 16px}
|
.sub{color:var(--yxg-sub);font-size:13px;margin:6px 0 16px}
|
||||||
.list{background:#fff;border-radius:16px;padding:8px 16px;line-height:2.2;font-size:14px;color:#555}
|
.err{color:var(--yxg-pri);font-size:13px}
|
||||||
.list a{color:inherit;text-decoration:none}
|
.list{background:#fff;border-radius:16px;padding:8px 16px;line-height:1.8;font-size:14px;color:#555}
|
||||||
|
.list a{color:inherit;text-decoration:none;font-weight:600}
|
||||||
|
.desc{display:block;font-size:12px;color:#aaa;font-weight:400;margin-bottom:8px}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<template>
|
||||||
|
<main class="page">
|
||||||
|
<button class="back" type="button" @click="$router.back()">← 返回</button>
|
||||||
|
<h1>{{ title || '探索测试' }}</h1>
|
||||||
|
<p class="sub">{{ description }}</p>
|
||||||
|
<p v-if="error" class="err">{{ error }}</p>
|
||||||
|
|
||||||
|
<div v-if="!done" class="card">
|
||||||
|
<div v-for="q in questions" :key="q.id" class="q">
|
||||||
|
<p class="prompt">{{ q.body.prompt }}</p>
|
||||||
|
<label v-for="opt in q.body.options" :key="opt.key" class="opt">
|
||||||
|
<input v-model="answers[q.id]" type="radio" :value="opt.key" />
|
||||||
|
{{ opt.text }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="button" :disabled="loading" @click="submit">查看探索结果</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="card">
|
||||||
|
<h2>{{ resultLabel }}</h2>
|
||||||
|
<p>{{ resultSummary }}</p>
|
||||||
|
<p class="share">{{ shareLine }}</p>
|
||||||
|
<router-link class="link" to="/relation">用结果去做关系理解 →</router-link>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const slug = String(route.params.slug || '')
|
||||||
|
const title = ref('')
|
||||||
|
const description = ref('')
|
||||||
|
const questions = ref<{ id: string; body: { prompt: string; options: { key: string; text: string }[] } }[]>([])
|
||||||
|
const answers = reactive<Record<string, string>>({})
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const done = ref(false)
|
||||||
|
const resultLabel = ref('')
|
||||||
|
const resultSummary = ref('')
|
||||||
|
const shareLine = ref('')
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const d = await api.getScale(slug)
|
||||||
|
title.value = d.title
|
||||||
|
description.value = d.description
|
||||||
|
questions.value = d.questions.map((q) => ({
|
||||||
|
id: q.id,
|
||||||
|
body: typeof q.body === 'string' ? JSON.parse(q.body) : q.body,
|
||||||
|
}))
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '加载失败'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
for (const q of questions.value) {
|
||||||
|
if (!answers[q.id]) throw new Error('请完成全部题目')
|
||||||
|
}
|
||||||
|
const { items } = await api.listProfiles()
|
||||||
|
let self = (items || []).find((p) => p.relation === 'self')
|
||||||
|
if (!self) {
|
||||||
|
self = await api.createProfile({ relation: 'self', birth_date: '1990-01-01', display_name: '我' })
|
||||||
|
}
|
||||||
|
const out = await api.submitScale(slug, self.id, { ...answers })
|
||||||
|
done.value = true
|
||||||
|
resultLabel.value = String(out.result.label || '探索结果')
|
||||||
|
resultSummary.value = String(out.result.summary || '')
|
||||||
|
shareLine.value = String(out.result.share_line || '')
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '提交失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page{padding:16px}
|
||||||
|
.back{border:none;background:#fff;border-radius:10px;padding:8px 12px;margin-bottom:12px}
|
||||||
|
h1{font-size:22px}
|
||||||
|
h2{font-size:18px;margin-bottom:8px}
|
||||||
|
.sub{color:var(--yxg-sub);font-size:13px;margin:8px 0 14px}
|
||||||
|
.err{color:var(--yxg-pri);font-size:13px}
|
||||||
|
.card{background:#fff;border-radius:16px;padding:18px;font-size:14px;line-height:1.7;color:#555}
|
||||||
|
.q{margin-bottom:18px}
|
||||||
|
.prompt{font-weight:600;color:#333;margin-bottom:8px}
|
||||||
|
.opt{display:block;margin:6px 0;cursor:pointer}
|
||||||
|
button{
|
||||||
|
margin-top:8px;padding:10px 16px;border:none;border-radius:22px;color:#fff;font-weight:600;
|
||||||
|
background:linear-gradient(135deg,#ff7a6e,var(--yxg-pri));
|
||||||
|
}
|
||||||
|
.share{margin-top:12px;color:var(--yxg-gold,#c8923a)}
|
||||||
|
.link{display:inline-block;margin-top:14px;color:var(--yxg-pri)}
|
||||||
|
</style>
|
||||||
@@ -12,6 +12,7 @@ const router = createRouter({
|
|||||||
{ path: '/portrait', name: 'portrait', component: () => import('../pages/PortraitPage.vue'), meta: { tab: false } },
|
{ path: '/portrait', name: 'portrait', component: () => import('../pages/PortraitPage.vue'), meta: { tab: false } },
|
||||||
{ path: '/relation', name: 'relation', component: () => import('../pages/RelationPage.vue'), meta: { tab: false } },
|
{ path: '/relation', name: 'relation', component: () => import('../pages/RelationPage.vue'), meta: { tab: false } },
|
||||||
{ path: '/membership', name: 'membership', component: () => import('../pages/MembershipPage.vue'), meta: { tab: false } },
|
{ path: '/membership', name: 'membership', component: () => import('../pages/MembershipPage.vue'), meta: { tab: false } },
|
||||||
|
{ path: '/scales/:slug', name: 'scale', component: () => import('../pages/ScalePage.vue'), meta: { tab: false } },
|
||||||
{ path: '/decode', redirect: (to) => ({ path: '/portrait', query: to.query }) },
|
{ path: '/decode', redirect: (to) => ({ path: '/portrait', query: to.query }) },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -73,6 +73,20 @@ export function createClient(opts: CreateClientOptions) {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { profile_a_id, profile_b_id },
|
body: { profile_a_id, profile_b_id },
|
||||||
}),
|
}),
|
||||||
|
listScales: () =>
|
||||||
|
call<{ items: { slug: string; title: string; description: string }[] }>('/api/v1/scales'),
|
||||||
|
getScale: (slug: string) =>
|
||||||
|
call<{
|
||||||
|
slug: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
questions: { id: string; sort: number; body: { prompt: string; options: { key: string; text: string }[] } }[]
|
||||||
|
}>(`/api/v1/scales/${slug}`),
|
||||||
|
submitScale: (slug: string, profile_id: string, answers: Record<string, string>) =>
|
||||||
|
call<{ id: string; result: Record<string, unknown> }>(`/api/v1/scales/${slug}/result`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { profile_id, answers },
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user