feat: add RelationInsight API and wire H5 relation/profile pages
Second P1 growth engine: compare two profiles, gate full tips behind deep-access mock payment, and list personal archives on /profile. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||||
"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/report"
|
"github.com/yuxingu/digital-psychology/apps/api/internal/service/report"
|
||||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||||
)
|
)
|
||||||
@@ -32,7 +33,7 @@ func main() {
|
|||||||
pool, err := db.Connect(ctx, cfg.DatabaseURL)
|
pool, err := db.Connect(ctx, cfg.DatabaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("database unavailable: %v", err)
|
log.Printf("database unavailable: %v", err)
|
||||||
log.Printf("hint: docker compose -f deploy/docker-compose.yml up -d")
|
log.Printf("hint: npm run deps:up (docker-compose.dev.yml)")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
@@ -46,11 +47,13 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
profileSvc := &profile.Service{Repo: &repository.ProfileRepo{Pool: pool}}
|
profileRepo := &repository.ProfileRepo{Pool: pool}
|
||||||
reportSvc := &report.Service{
|
reportRepo := &repository.ReportRepo{Pool: pool}
|
||||||
Profiles: &repository.ProfileRepo{Pool: pool},
|
relationRepo := &repository.RelationRepo{Pool: pool}
|
||||||
Reports: &repository.ReportRepo{Pool: pool},
|
|
||||||
}
|
profileSvc := &profile.Service{Repo: profileRepo}
|
||||||
|
reportSvc := &report.Service{Profiles: profileRepo, Reports: reportRepo}
|
||||||
|
relationSvc := &relation.Service{Profiles: profileRepo, Reports: reportRepo, Relations: relationRepo}
|
||||||
|
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(gin.Recovery(), gin.Logger(), middleware.RequestID())
|
r.Use(gin.Recovery(), gin.Logger(), middleware.RequestID())
|
||||||
@@ -69,6 +72,7 @@ func main() {
|
|||||||
authed.Use(middleware.DeviceAuth(pool))
|
authed.Use(middleware.DeviceAuth(pool))
|
||||||
(&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)
|
||||||
|
|
||||||
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,55 @@
|
|||||||
|
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/relation"
|
||||||
|
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RelationHandler exposes 关系理解 APIs.
|
||||||
|
type RelationHandler struct {
|
||||||
|
Svc *relation.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register mounts routes.
|
||||||
|
func (h *RelationHandler) Register(rg *gin.RouterGroup) {
|
||||||
|
rg.POST("/relation/insight", h.Create)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create handles POST /relation/insight.
|
||||||
|
func (h *RelationHandler) Create(c *gin.Context) {
|
||||||
|
userID, ok := middleware.UserIDFromContext(c)
|
||||||
|
if !ok {
|
||||||
|
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
ProfileAID string `json:"profile_a_id" binding:"required"`
|
||||||
|
ProfileBID string `json:"profile_b_id" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
aID, err := uuid.Parse(req.ProfileAID)
|
||||||
|
if err != nil {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 10000, "invalid profile_a_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bID, err := uuid.Parse(req.ProfileBID)
|
||||||
|
if err != nil {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 10000, "invalid profile_b_id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := h.Svc.Create(c.Request.Context(), userID, aID, bID)
|
||||||
|
if err != nil {
|
||||||
|
response.Fail(c, http.StatusBadRequest, 30005, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RelationInsight is a dual-profile understanding record.
|
||||||
|
type RelationInsight struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
UserID uuid.UUID `json:"user_id"`
|
||||||
|
ProfileAID uuid.UUID `json:"profile_a_id"`
|
||||||
|
ProfileBID uuid.UUID `json:"profile_b_id"`
|
||||||
|
Summary json.RawMessage `json:"summary"`
|
||||||
|
ReportID *uuid.UUID `json:"report_id,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Package relation builds 关系理解 content from two birth-based portraits.
|
||||||
|
package relation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yuxingu/digital-psychology/apps/api/internal/portrait"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Output is free summary + gated detail.
|
||||||
|
type Output struct {
|
||||||
|
Summary map[string]any
|
||||||
|
Detail map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build compares two profiles' exploration styles (lexicon-safe copy).
|
||||||
|
func Build(aBirth, bBirth time.Time, aName, bName string) Output {
|
||||||
|
if aName == "" {
|
||||||
|
aName = "我"
|
||||||
|
}
|
||||||
|
if bName == "" {
|
||||||
|
bName = "TA"
|
||||||
|
}
|
||||||
|
pa := portrait.Build(aBirth, aName)
|
||||||
|
pb := portrait.Build(bBirth, bName)
|
||||||
|
aLabel := str(pa.Summary["headline"])
|
||||||
|
bLabel := str(pb.Summary["headline"])
|
||||||
|
aKeys := strSlice(pa.Summary["keywords"])
|
||||||
|
bKeys := strSlice(pb.Summary["keywords"])
|
||||||
|
|
||||||
|
summary := map[string]any{
|
||||||
|
"title": "关系理解·基础",
|
||||||
|
"me_style": aLabel,
|
||||||
|
"other_style": bLabel,
|
||||||
|
"me_keywords": aKeys,
|
||||||
|
"other_keywords": bKeys,
|
||||||
|
"diff_one_liner": fmt.Sprintf("%s更偏内在节奏,%s的表达方式不同——差异可以成为互补,而不是对立。", aName, bName),
|
||||||
|
"share_hint": "了解彼此的沟通方式,查看关系理解",
|
||||||
|
}
|
||||||
|
detail := map[string]any{
|
||||||
|
"title": "相处建议·完整分析",
|
||||||
|
"communication": []string{
|
||||||
|
fmt.Sprintf("%s:先讲清楚事实与需要,再谈感受。", aName),
|
||||||
|
fmt.Sprintf("%s:先确认被听见,再进入方案讨论。", bName),
|
||||||
|
"冲突时约定「复述对方一句」再回应,降低误解。",
|
||||||
|
},
|
||||||
|
"interaction": []string{
|
||||||
|
"用「我观察到…我需要…」代替指责句式。",
|
||||||
|
"每周留一次轻松同步:最近什么在消耗/滋养这段关系。",
|
||||||
|
},
|
||||||
|
"maintenance": []string{
|
||||||
|
"差异标签不是评分,而是协作说明书。",
|
||||||
|
"重要决定前,双方各写三点顾虑再合并。",
|
||||||
|
},
|
||||||
|
"me_behavior": str(pa.Detail["behavior_pattern"]),
|
||||||
|
"other_behavior": str(pb.Detail["behavior_pattern"]),
|
||||||
|
}
|
||||||
|
return Output{Summary: summary, Detail: detail}
|
||||||
|
}
|
||||||
|
|
||||||
|
func str(v any) string {
|
||||||
|
s, _ := v.(string)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func strSlice(v any) []string {
|
||||||
|
arr, ok := v.([]string)
|
||||||
|
if ok {
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
raw, ok := v.([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(raw))
|
||||||
|
for _, x := range raw {
|
||||||
|
if s, ok := x.(string); ok {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package relation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuild(t *testing.T) {
|
||||||
|
a := time.Date(1990, 1, 15, 0, 0, 0, 0, time.UTC)
|
||||||
|
b := time.Date(1992, 6, 8, 0, 0, 0, 0, time.UTC)
|
||||||
|
out := Build(a, b, "我", "TA")
|
||||||
|
if out.Summary["diff_one_liner"] == nil {
|
||||||
|
t.Fatal("missing diff")
|
||||||
|
}
|
||||||
|
blob := str(out.Summary["diff_one_liner"]) + str(out.Detail["title"])
|
||||||
|
for _, bad := range []string{"合盘", "运势", "吉凶", "合婚"} {
|
||||||
|
if strings.Contains(blob, bad) {
|
||||||
|
t.Fatalf("forbidden %q", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RelationRepo persists relation insights.
|
||||||
|
type RelationRepo struct {
|
||||||
|
Pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create inserts a relation insight row.
|
||||||
|
func (r *RelationRepo) Create(ctx context.Context, userID, aID, bID uuid.UUID, summary json.RawMessage, reportID uuid.UUID) (*model.RelationInsight, error) {
|
||||||
|
row := &model.RelationInsight{}
|
||||||
|
err := r.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO relation_insights(user_id, profile_a_id, profile_b_id, summary, report_id)
|
||||||
|
VALUES ($1,$2,$3,$4,$5)
|
||||||
|
RETURNING id, user_id, profile_a_id, profile_b_id, summary, report_id, created_at`,
|
||||||
|
userID, aID, bID, summary, reportID,
|
||||||
|
).Scan(&row.ID, &row.UserID, &row.ProfileAID, &row.ProfileBID, &row.Summary, &row.ReportID, &row.CreatedAt)
|
||||||
|
return row, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package relation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
|
||||||
|
releng "github.com/yuxingu/digital-psychology/apps/api/internal/relation"
|
||||||
|
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service creates relation understanding reports.
|
||||||
|
type Service struct {
|
||||||
|
Profiles *repository.ProfileRepo
|
||||||
|
Reports *repository.ReportRepo
|
||||||
|
Relations *repository.RelationRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateInsightResult is API payload.
|
||||||
|
type CreateInsightResult struct {
|
||||||
|
Insight *model.RelationInsight `json:"insight"`
|
||||||
|
Report *model.GrowthReport `json:"report"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create builds 关系理解 for two owned profiles.
|
||||||
|
func (s *Service) Create(ctx context.Context, userID, aID, bID uuid.UUID) (*CreateInsightResult, error) {
|
||||||
|
if aID == bID {
|
||||||
|
return nil, errors.New("need two different profiles")
|
||||||
|
}
|
||||||
|
pa, err := s.Profiles.GetForUser(ctx, userID, aID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("profile_a not found")
|
||||||
|
}
|
||||||
|
pb, err := s.Profiles.GetForUser(ctx, userID, bID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("profile_b not found")
|
||||||
|
}
|
||||||
|
out := releng.Build(pa.BirthDate, pb.BirthDate, pa.DisplayName, pb.DisplayName)
|
||||||
|
sum, _ := json.Marshal(out.Summary)
|
||||||
|
det, _ := json.Marshal(out.Detail)
|
||||||
|
rep, err := s.Reports.Create(ctx, userID, aID, "relation", sum, det)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ins, err := s.Relations.Create(ctx, userID, aID, bID, sum, rep.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// entitlement trim on report
|
||||||
|
deep, _ := s.Reports.HasDeepAccess(ctx, userID, rep.ID)
|
||||||
|
vip, _ := s.Reports.HasActiveMembership(ctx, userID)
|
||||||
|
rep.HasDeep = deep || vip
|
||||||
|
if !rep.HasDeep {
|
||||||
|
rep.Detail = nil
|
||||||
|
}
|
||||||
|
return &CreateInsightResult{Insight: ins, Report: rep}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS relation_insights;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS relation_insights (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id),
|
||||||
|
profile_a_id uuid NOT NULL REFERENCES profiles(id),
|
||||||
|
profile_b_id uuid NOT NULL REFERENCES profiles(id),
|
||||||
|
summary jsonb NOT NULL DEFAULT '{}',
|
||||||
|
report_id uuid NULL REFERENCES growth_reports(id),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_relation_insights_user_id ON relation_insights(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_relation_insights_profiles ON relation_insights(profile_a_id, profile_b_id);
|
||||||
@@ -3,18 +3,49 @@
|
|||||||
<button class="back" type="button" @click="$router.back()">← 返回</button>
|
<button class="back" type="button" @click="$router.back()">← 返回</button>
|
||||||
<h1>个人档案</h1>
|
<h1>个人档案</h1>
|
||||||
<p class="sub">管理我的信息与重要的人</p>
|
<p class="sub">管理我的信息与重要的人</p>
|
||||||
<div class="card">
|
<p v-if="error" class="err">{{ error }}</p>
|
||||||
支持创建我的档案、添加伴侣/家人/朋友,并在问答中切换解读对象。数据接口:/api/v1/profiles。
|
<ul v-if="items.length" class="list">
|
||||||
</div>
|
<li v-for="p in items" :key="p.id">
|
||||||
<router-link class="link" to="/relation">去完善关系理解 →</router-link>
|
<strong>{{ p.display_name || (p.relation === 'self' ? '我' : 'TA') }}</strong>
|
||||||
|
· {{ p.relation === 'self' ? '我' : '关系对象' }}
|
||||||
|
· {{ formatDate(p.birth_date) }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div v-else class="card">暂无档案。请先去首页完成性格探索。</div>
|
||||||
|
<router-link class="link" to="/">去性格探索 →</router-link>
|
||||||
|
<router-link class="link" to="/relation">去关系理解 →</router-link>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import type { Profile } from '@yuxingu/types'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
|
||||||
|
const items = ref<Profile[]>([])
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
function formatDate(v: string) {
|
||||||
|
return (v || '').slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.listProfiles()
|
||||||
|
items.value = res.items || []
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '加载失败'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page{padding:16px}
|
.page{padding:16px}
|
||||||
.back{border:none;background:#fff;border-radius:10px;padding:8px 12px;margin-bottom:12px}
|
.back{border:none;background:#fff;border-radius:10px;padding:8px 12px;margin-bottom:12px}
|
||||||
h1{font-size:22px}
|
h1{font-size:22px}
|
||||||
.sub{color:var(--yxg-sub);margin:8px 0 14px;font-size:13px}
|
.sub{color:var(--yxg-sub);margin:8px 0 14px;font-size:13px}
|
||||||
.card{background:#fff;border-radius:16px;padding:18px;font-size:14px;line-height:1.7;color:#555}
|
.err{color:var(--yxg-pri);font-size:13px}
|
||||||
.link{display:inline-block;margin-top:16px;font-size:14px;color:var(--yxg-pri)}
|
.card{background:#fff;border-radius:16px;padding:18px;font-size:14px;color:#555}
|
||||||
|
.list{background:#fff;border-radius:16px;padding:8px 16px;line-height:2.1;font-size:14px;color:#555;margin-bottom:12px}
|
||||||
|
.link{display:block;margin-top:12px;font-size:14px;color:var(--yxg-pri)}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,16 +3,143 @@
|
|||||||
<button class="back" type="button" @click="$router.back()">← 返回</button>
|
<button class="back" type="button" @click="$router.back()">← 返回</button>
|
||||||
<h1>关系理解</h1>
|
<h1>关系理解</h1>
|
||||||
<p class="sub">添加重要的人,了解双方差异与相处建议</p>
|
<p class="sub">添加重要的人,了解双方差异与相处建议</p>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
双人分析与深度相处建议将在 P1 接入 API。概览免费可见,完整建议为深度版或会员权益。
|
<label>TA 的称呼</label>
|
||||||
|
<input v-model="otherName" placeholder="例如:伴侣" />
|
||||||
|
<label>TA 的生日</label>
|
||||||
|
<div class="row">
|
||||||
|
<input v-model.number="oy" type="number" placeholder="年" />
|
||||||
|
<input v-model.number="om" type="number" placeholder="月" />
|
||||||
|
<input v-model.number="od" type="number" placeholder="日" />
|
||||||
|
</div>
|
||||||
|
<p class="hint">将使用你最近一份「我」的档案进行对比;若没有会先引导去首页创建个人画像。</p>
|
||||||
|
<button type="button" :disabled="loading" @click="run">生成关系理解</button>
|
||||||
|
<p v-if="error" class="err">{{ error }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<template v-if="report">
|
||||||
|
<div class="card">
|
||||||
|
<p><strong>我</strong>:{{ meStyle }}</p>
|
||||||
|
<p><strong>TA</strong>:{{ otherStyle }}</p>
|
||||||
|
<p class="line">{{ diff }}</p>
|
||||||
|
<div class="tags">
|
||||||
|
<span v-for="k in meKeys" :key="'a'+k">我·{{ k }}</span>
|
||||||
|
<span v-for="k in otherKeys" :key="'b'+k">TA·{{ k }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="report.has_deep_access && detail" class="card">
|
||||||
|
<h2>相处建议</h2>
|
||||||
|
<ul>
|
||||||
|
<li v-for="(t, i) in tips" :key="i">{{ t }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div v-else class="card lock">
|
||||||
|
<p>完整相处建议可在深度版或成长会员中查看。</p>
|
||||||
|
<button type="button" :disabled="paying" @click="buyDeep">查看深度版(模拟支付)</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import type { GrowthReport, Profile } from '@yuxingu/types'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
|
||||||
|
const otherName = ref('TA')
|
||||||
|
const oy = ref<number | null>(1992)
|
||||||
|
const om = ref<number | null>(6)
|
||||||
|
const od = ref<number | null>(8)
|
||||||
|
const loading = ref(false)
|
||||||
|
const paying = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const report = ref<GrowthReport | null>(null)
|
||||||
|
|
||||||
|
const summary = computed(() => (report.value?.summary || {}) as Record<string, unknown>)
|
||||||
|
const detail = computed(() => (report.value?.detail || null) as Record<string, unknown> | null)
|
||||||
|
const meStyle = computed(() => String(summary.value.me_style || ''))
|
||||||
|
const otherStyle = computed(() => String(summary.value.other_style || ''))
|
||||||
|
const diff = computed(() => String(summary.value.diff_one_liner || ''))
|
||||||
|
const meKeys = computed(() => (Array.isArray(summary.value.me_keywords) ? summary.value.me_keywords as string[] : []))
|
||||||
|
const otherKeys = computed(() => (Array.isArray(summary.value.other_keywords) ? summary.value.other_keywords as string[] : []))
|
||||||
|
const tips = computed(() => {
|
||||||
|
const d = detail.value
|
||||||
|
if (!d) return [] as string[]
|
||||||
|
const a = Array.isArray(d.communication) ? d.communication as string[] : []
|
||||||
|
const b = Array.isArray(d.interaction) ? d.interaction as string[] : []
|
||||||
|
const c = Array.isArray(d.maintenance) ? d.maintenance as string[] : []
|
||||||
|
return [...a, ...b, ...c]
|
||||||
|
})
|
||||||
|
|
||||||
|
async function ensureSelf(): Promise<Profile> {
|
||||||
|
const { items } = await api.listProfiles()
|
||||||
|
const self = (items || []).find((p) => p.relation === 'self')
|
||||||
|
if (self) return self
|
||||||
|
throw new Error('请先在首页完成性格探索,创建「我」的个人档案')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
report.value = null
|
||||||
|
try {
|
||||||
|
if (!oy.value || !om.value || !od.value) {
|
||||||
|
throw new Error('请填写 TA 的完整生日')
|
||||||
|
}
|
||||||
|
const self = await ensureSelf()
|
||||||
|
const birth = `${oy.value}-${String(om.value).padStart(2, '0')}-${String(od.value).padStart(2, '0')}`
|
||||||
|
const other = await api.createProfile({
|
||||||
|
relation: 'other',
|
||||||
|
birth_date: birth,
|
||||||
|
display_name: otherName.value || 'TA',
|
||||||
|
relation_type: 'partner',
|
||||||
|
})
|
||||||
|
const out = await api.createRelationInsight(self.id, other.id)
|
||||||
|
report.value = out.report
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '生成失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buyDeep() {
|
||||||
|
if (!report.value) return
|
||||||
|
paying.value = true
|
||||||
|
try {
|
||||||
|
const { order_id } = await api.createOrder({ kind: 'deep_access', report_id: report.value.id })
|
||||||
|
await api.payMock(order_id)
|
||||||
|
report.value = await api.getReport(report.value.id)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '支付失败'
|
||||||
|
} finally {
|
||||||
|
paying.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page{padding:16px}
|
.page{padding:16px}
|
||||||
.back{border:none;background:#fff;border-radius:10px;padding:8px 12px;margin-bottom:12px}
|
.back{border:none;background:#fff;border-radius:10px;padding:8px 12px;margin-bottom:12px}
|
||||||
h1{font-size:22px}
|
h1{font-size:22px}
|
||||||
|
h2{font-size:16px;margin-bottom:8px}
|
||||||
.sub{color:var(--yxg-sub);margin:8px 0 14px;font-size:13px}
|
.sub{color:var(--yxg-sub);margin:8px 0 14px;font-size:13px}
|
||||||
.card{background:#fff;border-radius:16px;padding:18px;font-size:14px;line-height:1.7;color:#555}
|
.card{background:#fff;border-radius:16px;padding:18px;font-size:14px;line-height:1.7;color:#555;margin-bottom:12px}
|
||||||
|
label{display:block;font-size:12px;color:#999;margin:8px 0 4px}
|
||||||
|
input{width:100%;box-sizing:border-box;padding:10px;border:1.5px solid #eee;border-radius:12px;margin-bottom:4px}
|
||||||
|
.row{display:flex;gap:8px}
|
||||||
|
.row input{flex:1}
|
||||||
|
.hint{font-size:12px;color:#aaa;margin:8px 0}
|
||||||
|
button{
|
||||||
|
margin-top:10px;padding:10px 16px;border:none;border-radius:22px;color:#fff;font-weight:600;
|
||||||
|
background:linear-gradient(135deg,#ff7a6e,var(--yxg-pri));
|
||||||
|
}
|
||||||
|
button:disabled{opacity:.6}
|
||||||
|
.err{color:var(--yxg-pri);font-size:13px;margin-top:8px}
|
||||||
|
.line{margin-top:8px;color:#333}
|
||||||
|
.tags{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
|
||||||
|
.tags span{background:#fff3d6;color:#c8923a;padding:4px 10px;border-radius:999px;font-size:12px}
|
||||||
|
ul{padding-left:18px;margin:0}
|
||||||
|
li{margin:6px 0}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -65,6 +65,14 @@ export function createClient(opts: CreateClientOptions) {
|
|||||||
call<{ order_id: string }>('/api/v1/orders', { method: 'POST', body }),
|
call<{ order_id: string }>('/api/v1/orders', { method: 'POST', body }),
|
||||||
payMock: (orderId: string) =>
|
payMock: (orderId: string) =>
|
||||||
call<{ paid: boolean }>(`/api/v1/orders/${orderId}/pay-mock`, { method: 'POST' }),
|
call<{ paid: boolean }>(`/api/v1/orders/${orderId}/pay-mock`, { method: 'POST' }),
|
||||||
|
createRelationInsight: (profile_a_id: string, profile_b_id: string) =>
|
||||||
|
call<{
|
||||||
|
insight: { id: string; report_id?: string }
|
||||||
|
report: GrowthReport
|
||||||
|
}>('/api/v1/relation/insight', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { profile_a_id, profile_b_id },
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user