feat(ECR-012–016): 合规、题库、时辰刷新、头像、MBTI OEJTS 与埋点
落地输入合规、探索题库、报告日/时辰刷新、账号头像、OEJTS 量表,并补齐 H5 埋点与 Admin 漏斗;同步 ESS 工件、切至自建 Git、清理 GitHub Actions。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -92,6 +92,11 @@ export function createClient(opts: CreateClientOptions) {
|
||||
authMe: () => call<AuthUser>('/api/v1/auth/me'),
|
||||
authUpdateMe: (body: { nickname: string }) =>
|
||||
call<AuthUser>('/api/v1/auth/me', { method: 'PATCH', body }),
|
||||
authUploadAvatar: (file: Blob, filename = 'avatar.jpg') => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file, filename)
|
||||
return call<AuthUser>('/api/v1/auth/me/avatar', { method: 'POST', body: fd })
|
||||
},
|
||||
listProfiles: () => call<{ items: Profile[] }>('/api/v1/profiles'),
|
||||
createProfile: (body: CreateProfileBody) =>
|
||||
call<Profile>('/api/v1/profiles', { method: 'POST', body }),
|
||||
@@ -142,7 +147,7 @@ export function createClient(opts: CreateClientOptions) {
|
||||
listGrowthPlans: () => call<{ items: GrowthPlan[] }>('/api/v1/growth/plans'),
|
||||
createGrowthPlan: (body: { title: string; focus?: string }) =>
|
||||
call<GrowthPlan>('/api/v1/growth/plans', { method: 'POST', body }),
|
||||
growthCheckin: (planId: string, body?: { note?: string }) =>
|
||||
createGrowthCheckin: (planId: string, body?: { note?: string }) =>
|
||||
call<{ id: string; day: string }>(`/api/v1/growth/plans/${planId}/checkin`, {
|
||||
method: 'POST',
|
||||
body: body || {},
|
||||
@@ -162,7 +167,55 @@ export function createClient(opts: CreateClientOptions) {
|
||||
body: { profile_a_id, profile_b_id },
|
||||
}),
|
||||
listScales: () => call<{ items: ScaleSummary[] }>('/api/v1/scales'),
|
||||
getScaleBankCatalog: () =>
|
||||
call<{
|
||||
featured: {
|
||||
slug: string
|
||||
title: string
|
||||
description: string
|
||||
category_key: string
|
||||
icon: string
|
||||
path: string
|
||||
}[]
|
||||
categories: {
|
||||
key: string
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
count: number
|
||||
featured: {
|
||||
slug: string
|
||||
title: string
|
||||
description: string
|
||||
category_key: string
|
||||
icon: string
|
||||
path: string
|
||||
}[]
|
||||
path: string
|
||||
}[]
|
||||
}>('/api/v1/scale-bank/catalog'),
|
||||
getScaleBankCategory: (key: string) =>
|
||||
call<{
|
||||
category: {
|
||||
key: string
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
count: number
|
||||
path: string
|
||||
}
|
||||
items: {
|
||||
slug: string
|
||||
title: string
|
||||
description: string
|
||||
icon?: string
|
||||
question_count: number
|
||||
path: string
|
||||
}[]
|
||||
}>(`/api/v1/scale-bank/categories/${key}`),
|
||||
getScale: (slug: string) => call<ScaleDetail>(`/api/v1/scales/${slug}`),
|
||||
getScaleResult: (slug: string) =>
|
||||
call<ScaleSubmitResult>(`/api/v1/scales/${slug}/result`),
|
||||
submitScale: (slug: string, profile_id: string, answers: Record<string, string>) =>
|
||||
call<ScaleSubmitResult>(`/api/v1/scales/${slug}/result`, {
|
||||
method: 'POST',
|
||||
@@ -334,13 +387,14 @@ export function createBrowserAdapters(): ClientAdapters {
|
||||
const deviceKeyStorage = 'yxg_device_key'
|
||||
return {
|
||||
request: async <T>({ method = 'GET', path, body, headers, keepalive }: RequestOptions) => {
|
||||
const isForm = typeof FormData !== 'undefined' && body instanceof FormData
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(isForm ? {} : { 'Content-Type': 'application/json' }),
|
||||
...(headers || {}),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
body: body === undefined ? undefined : isForm ? (body as FormData) : JSON.stringify(body),
|
||||
keepalive: keepalive === true,
|
||||
})
|
||||
const text = await res.text()
|
||||
|
||||
@@ -58,12 +58,15 @@ export interface AuthUser {
|
||||
id: string
|
||||
phone: string
|
||||
nickname: string
|
||||
avatar_url?: string
|
||||
}
|
||||
|
||||
export interface AuthSession {
|
||||
token: string
|
||||
expires_at: string
|
||||
user: AuthUser
|
||||
/** true when OpenLogin created a new account (auto-register) */
|
||||
is_new?: boolean
|
||||
}
|
||||
|
||||
/** 探索测试列表项 — 对齐 Go ScaleListItem / listScales 响应 */
|
||||
@@ -78,19 +81,28 @@ export interface ScaleQuestionOption {
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface ScaleQuestion {
|
||||
id: string
|
||||
sort: number
|
||||
body: { prompt: string; options: ScaleQuestionOption[] }
|
||||
}
|
||||
|
||||
export interface ScaleDetail {
|
||||
slug: string
|
||||
title: string
|
||||
description: string
|
||||
access?: 'free' | 'membership' | string
|
||||
locked?: boolean
|
||||
questions: ScaleQuestion[]
|
||||
}
|
||||
|
||||
export interface ScaleQuestion {
|
||||
id: string
|
||||
sort: number
|
||||
body: {
|
||||
prompt: string
|
||||
format?: string
|
||||
left?: string
|
||||
right?: string
|
||||
dimension?: string
|
||||
options: ScaleQuestionOption[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface ScaleSubmitResult {
|
||||
id: string
|
||||
result: Record<string, unknown>
|
||||
@@ -197,6 +209,10 @@ export interface HomeDailyTips {
|
||||
source: 'llm' | 'fallback' | string
|
||||
gua_name?: string
|
||||
day_part?: string
|
||||
as_of?: string
|
||||
shichen?: number
|
||||
shichen_name?: string
|
||||
valid_until?: string
|
||||
need_birth?: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
--shadow-sheet: 0 -4px 24px rgba(180, 100, 80, 0.06);
|
||||
--shadow-nav: 0 -2px 8px rgba(0, 0, 0, 0.03);
|
||||
--layout-max-user: 430px;
|
||||
--layout-nav-h: 56px;
|
||||
--layout-nav-h: 64px;
|
||||
|
||||
/* ── Motion ── */
|
||||
--duration-fast: 150ms;
|
||||
|
||||
@@ -18,3 +18,10 @@ export function validateBirth(year: number, month: number, day: number): string
|
||||
if (day < 1 || day > 31) return '日期无效'
|
||||
return null
|
||||
}
|
||||
|
||||
export {
|
||||
normalizeUserText,
|
||||
validateUserText,
|
||||
type UserTextKind,
|
||||
type ValidateUserTextResult,
|
||||
} from './textsafe'
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/** User free-text kinds (mirror apps/api/internal/textsafe). */
|
||||
export type UserTextKind =
|
||||
| 'nickname'
|
||||
| 'display_name'
|
||||
| 'ask_content'
|
||||
| 'note'
|
||||
| 'title'
|
||||
| 'focus'
|
||||
| 'scene'
|
||||
|
||||
const limits: Record<UserTextKind, { min: number; max: number; allowNL: boolean }> = {
|
||||
nickname: { min: 1, max: 16, allowNL: false },
|
||||
display_name: { min: 1, max: 24, allowNL: false },
|
||||
ask_content: { min: 1, max: 2000, allowNL: true },
|
||||
note: { min: 0, max: 200, allowNL: true },
|
||||
title: { min: 1, max: 40, allowNL: false },
|
||||
focus: { min: 0, max: 40, allowNL: false },
|
||||
scene: { min: 1, max: 80, allowNL: false },
|
||||
}
|
||||
|
||||
const banned = [
|
||||
'占卜',
|
||||
'算命',
|
||||
'改命',
|
||||
'测测',
|
||||
'疗效',
|
||||
'包治',
|
||||
'根治',
|
||||
'治疗疾病',
|
||||
'治愈癌症',
|
||||
'不测就有灾',
|
||||
'大师算卦',
|
||||
'神棍',
|
||||
]
|
||||
|
||||
/** Normalize like backend textsafe.Normalize. */
|
||||
export function normalizeUserText(raw: string, allowNL: boolean): string {
|
||||
let s = raw.trim()
|
||||
if (!allowNL) {
|
||||
s = s.replace(/[\n\r\t]/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
function rejectControls(s: string, allowNL: boolean): string | null {
|
||||
for (const ch of s) {
|
||||
const code = ch.codePointAt(0) ?? 0
|
||||
if (code === 0) return '含非法字符'
|
||||
if (ch === '\n' || ch === '\r' || ch === '\t') {
|
||||
if (!allowNL) return '含非法空白'
|
||||
continue
|
||||
}
|
||||
if (code < 0x20 || code === 0x7f) return '含非法字符'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function rejectMarkup(s: string): string | null {
|
||||
const low = s.toLowerCase()
|
||||
if (low.includes('<script') || low.includes('javascript:')) return '含不安全内容'
|
||||
if (low.includes('onerror=') || low.includes('onload=')) return '含不安全内容'
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
if (s[i] !== '<') continue
|
||||
const c = s[i + 1]
|
||||
if (!c) continue
|
||||
if (c === '/' || c === '!' || /[a-zA-Z]/.test(c)) return '含不安全内容'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function rejectBanned(s: string): string | null {
|
||||
for (const b of banned) {
|
||||
if (b && s.includes(b)) return '含不允许的表述,请换一种说法'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function rejectSpam(s: string): string | null {
|
||||
const runes = [...s]
|
||||
if (runes.length >= 6) {
|
||||
if (runes.every((r) => r === runes[0]) && runes[0]?.trim()) return '请勿重复刷屏'
|
||||
}
|
||||
let streak = 1
|
||||
for (let i = 1; i < runes.length; i++) {
|
||||
if (runes[i] === runes[i - 1] && runes[i]?.trim()) {
|
||||
streak++
|
||||
if (streak >= 8) return '请勿重复刷屏'
|
||||
} else {
|
||||
streak = 1
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export type ValidateUserTextResult =
|
||||
| { ok: true; value: string }
|
||||
| { ok: false; message: string }
|
||||
|
||||
/**
|
||||
* Frontend mirror of API textsafe.Check.
|
||||
* Backend remains authoritative (HTTP 400 / code 40060).
|
||||
*/
|
||||
export function validateUserText(kind: UserTextKind, raw: string): ValidateUserTextResult {
|
||||
const lim = limits[kind]
|
||||
const s = normalizeUserText(raw, lim.allowNL)
|
||||
const n = [...s].length
|
||||
if (n < lim.min) {
|
||||
if (lim.min === 0) return { ok: true, value: '' }
|
||||
return { ok: false, message: '文案不合规:请填写内容' }
|
||||
}
|
||||
if (n > lim.max) return { ok: false, message: '文案不合规:内容过长' }
|
||||
const ctrl = rejectControls(s, lim.allowNL)
|
||||
if (ctrl) return { ok: false, message: `文案不合规:${ctrl}` }
|
||||
const markup = rejectMarkup(s)
|
||||
if (markup) return { ok: false, message: `文案不合规:${markup}` }
|
||||
const ban = rejectBanned(s)
|
||||
if (ban) return { ok: false, message: `文案不合规:${ban}` }
|
||||
const spam = rejectSpam(s)
|
||||
if (spam) return { ok: false, message: `文案不合规:${spam}` }
|
||||
return { ok: true, value: s }
|
||||
}
|
||||
Reference in New Issue
Block a user