同步小程序与管理端更新,完善抽烟记录和解绑冷静期

This commit is contained in:
2026-10-09 22:15:51 +08:00
parent a68a4ef411
commit b10fc3c4ac
149 changed files with 3794 additions and 589 deletions
+125
View File
@@ -0,0 +1,125 @@
// dbcheck runs only SELECT/SHOW statements. It never initializes or migrates a database.
package main
import (
"context"
"database/sql"
"encoding/json"
"flag"
"fmt"
"os"
"path/filepath"
"time"
_ "github.com/go-sql-driver/mysql"
"past/internal/config"
)
type result struct {
Name string `json:"name"`
Rows []map[string]any `json:"rows,omitempty"`
Error string `json:"error,omitempty"`
}
func query(db *sql.DB, name, statement string) result {
r := result{Name: name}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
rows, err := db.QueryContext(ctx, statement)
if err != nil {
r.Error = err.Error()
return r
}
defer rows.Close()
columns, err := rows.Columns()
if err != nil {
r.Error = err.Error()
return r
}
for rows.Next() {
values := make([]any, len(columns))
ptrs := make([]any, len(columns))
for i := range values {
ptrs[i] = &values[i]
}
if err := rows.Scan(ptrs...); err != nil {
r.Error = err.Error()
return r
}
row := map[string]any{}
for i, key := range columns {
if b, ok := values[i].([]byte); ok {
row[key] = string(b)
} else {
row[key] = values[i]
}
}
r.Rows = append(r.Rows, row)
}
if err := rows.Err(); err != nil {
r.Error = err.Error()
}
return r
}
func run() error {
exe, err := os.Executable()
if err != nil {
return err
}
configPath := flag.String("config", filepath.Join(filepath.Dir(exe), "config.yaml"), "Existing server config.yaml; never upload its contents")
flag.Parse()
cfg, err := config.Load(*configPath)
if err != nil {
return err
}
db, err := sql.Open("mysql", cfg.DSN())
if err != nil {
return fmt.Errorf("cannot open database connection")
}
defer db.Close()
db.SetMaxOpenConns(1)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
return fmt.Errorf("database connection failed; check the original config and MySQL service locally")
}
report := []result{
query(db, "database", "SELECT DATABASE() AS database_name, VERSION() AS version, NOW() AS database_time"),
query(db, "binary_log_settings", "SHOW VARIABLES WHERE Variable_name IN ('log_bin','log_bin_basename','binlog_format','binlog_row_image','datadir')"),
query(db, "binary_log_files", "SHOW BINARY LOGS"),
query(db, "relationship_status", "SELECT status,COUNT(*) AS records FROM couples GROUP BY status"),
query(db, "relationship_mismatches", "SELECT c.id,c.status,c.user1_id,c.user2_id,u1.couple_id AS user1_current_couple,u2.couple_id AS user2_current_couple FROM couples c LEFT JOIN users u1 ON u1.id=c.user1_id LEFT JOIN users u2 ON u2.id=c.user2_id WHERE c.status='active' AND (u1.id IS NULL OR u2.id IS NULL OR COALESCE(u1.couple_id,0)<>c.id OR COALESCE(u2.couple_id,0)<>c.id) ORDER BY c.id"),
query(db, "migration_marker", "SELECT `key`,`value` FROM settings WHERE `key`='account_records_v1'"),
}
for _, table := range []string{"users", "couples", "diaries", "albums", "photos", "timeline", "songs", "checkins", "period_records", "period_daily_records", "period_sex_records", "personal_period_records", "personal_period_daily_records", "personal_period_sex_records", "smoking_wallets", "smoking_entries"} {
report = append(report, query(db, "count_"+table, "SELECT COUNT(*) AS records FROM "+table))
}
for _, table := range []string{"diaries", "albums", "timeline", "songs", "checkins"} {
report = append(report, query(db, "space_"+table, "SELECT d.couple_id,c.status,COUNT(*) AS records,SUM(CASE WHEN d.created_by IN (c.user1_id,c.user2_id) THEN 1 ELSE 0 END) AS matching_author,SUM(CASE WHEN d.created_by IS NULL OR d.created_by=0 THEN 1 ELSE 0 END) AS missing_author FROM "+table+" d LEFT JOIN couples c ON c.id=d.couple_id GROUP BY d.couple_id,c.status ORDER BY d.couple_id"))
}
f, err := os.CreateTemp(filepath.Dir(exe), "dbcheck-report-*.json")
if err != nil {
return err
}
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
encodeErr := enc.Encode(report)
closeErr := f.Close()
if encodeErr != nil {
return encodeErr
}
if closeErr != nil {
return closeErr
}
fmt.Println("Read-only check complete. No migration or data changes performed.")
fmt.Println("Report:", f.Name())
return nil
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
+10
View File
@@ -98,12 +98,22 @@ func serve(cfgPath string, stop <-chan struct{}) {
slog.Error("数据库连接失败", "err", err) slog.Error("数据库连接失败", "err", err)
os.Exit(1) os.Exit(1)
} }
db.UploadDir = cfg.UploadDir()
db.NumericAsString = cfg.PHPCompat < "8.1" db.NumericAsString = cfg.PHPCompat < "8.1"
if err := db.InitSchema(); err != nil { if err := db.InitSchema(); err != nil {
slog.Error("数据库初始化失败", "err", err) slog.Error("数据库初始化失败", "err", err)
os.Exit(1) os.Exit(1)
} }
if err := db.MigrateSmokingRecords(); err != nil {
slog.Error("抽烟库存迁移失败", "err", err)
os.Exit(1)
}
if err := db.AdvanceUnbindings(); err != nil {
slog.Error("解绑状态处理失败", "err", err)
os.Exit(1)
}
go db.RunUnbindingWorker()
wx := wechat.New(cfg.Wechat.AppID, cfg.Wechat.Secret, cfg.Wechat.NotifyTemplateID, cfg.DataDir, db) wx := wechat.New(cfg.Wechat.AppID, cfg.Wechat.Secret, cfg.Wechat.NotifyTemplateID, cfg.DataDir, db)
app := &httpapi.App{ app := &httpapi.App{
Cfg: cfg, Cfg: cfg,
+24
View File
@@ -1,3 +1,27 @@
# 20261008 原权限兼容与抽烟功能
## 当前行为
- 日记、相册、记忆日、歌曲和普通打卡恢复原版按情侣成员作者查询;历史 couple_id 不再成为新增过滤条件。未绑定展示方式恢复原版。
- 姨姨继续使用 period_records、period_daily_records、period_sex_records,恢复原版共享记录、来源选择和可见开关。启动不复制或切换姨姨表。此前创建的 personal_period_* 表保留,但本版不读写。
- 照片响应保持原始 uploads/... 路径,不签名、不改成 /backend/uploads/...;上传静态文件恢复原访问方式,兼容正式版小程序。
- 抽烟使用独立的 smoking_wallets 和 smoking_entries;消耗一支扣一支、买烟和别人递烟加库存、撤销操作保持事务和幂等。库存随账号保留。
- 72 小时解绑冷静期继续保留:双方同意后开始计时,期间可取消,到期解除绑定且保留记录。其他数据权限按原版运行。
## 部署
1. 在宝塔备份当前数据库和 uploads,保存原可运行 exe;停止现有 Go 服务。
2. 仅替换 past-server.exe,保留现有 config.yaml、data_dir、uploads、data 和数据库。无需重新配置 Nginx,已正常工作的 uploads 转发继续使用。
3. 启动时补齐抽烟表和冷静期字段;仅迁移旧抽烟库存与流水。smoking_records_v1=done 后不会重复;已经完成 account_records_v1 的环境也不会重新导入库存。不存在姨姨数据迁移。
4. 先验证正式版日记、相册、照片、记忆日、歌曲、普通打卡和姨姨记录,再用新版小程序验证抽烟。
5. 新版小程序的姨姨页面已同步恢复共享方式;抽烟需要上传新版小程序才能显示。
Go 全套和前端回归在本机执行;尚未在生产数据库或微信真机运行本版。历史 binlog 和检查报告显示原记录在库,不应通过清表或倒放日志部署本版。
以下为 20261005 历史说明,当前部署以以上步骤为准。
---
# 20261005 UI 精修与驼峰 API 更新 # 20261005 UI 精修与驼峰 API 更新
## 部署顺序 ## 部署顺序
@@ -0,0 +1,42 @@
-- 只读排查:在原业务数据库中执行。不要修改关系、记录归属或迁移标记。
SELECT DATABASE() AS current_database;
SELECT 'users' AS table_name, COUNT(*) AS records FROM users
UNION ALL SELECT 'couples', COUNT(*) FROM couples
UNION ALL SELECT 'diaries', COUNT(*) FROM diaries
UNION ALL SELECT 'albums', COUNT(*) FROM albums
UNION ALL SELECT 'photos', COUNT(*) FROM photos
UNION ALL SELECT 'timeline', COUNT(*) FROM timeline
UNION ALL SELECT 'checkins', COUNT(*) FROM checkins
UNION ALL SELECT 'songs', COUNT(*) FROM songs
UNION ALL SELECT 'period_records', COUNT(*) FROM period_records;
-- 关系一致性:只显示内部 ID,不输出手机号、密码、token 等信息。
SELECT c.id AS couple_id, c.status, c.user1_id, c.user2_id,
u1.couple_id AS user1_current_couple, u2.couple_id AS user2_current_couple
FROM couples c
LEFT JOIN users u1 ON u1.id = c.user1_id
LEFT JOIN users u2 ON u2.id = c.user2_id
ORDER BY c.id;
-- 按历史空间检查记录是否还在,以及新的作者过滤是否排除了旧记录。
SELECT 'diaries' AS table_name, d.couple_id, c.status,
COUNT(*) AS records,
SUM(CASE WHEN d.created_by IN (c.user1_id,c.user2_id) THEN 1 ELSE 0 END) AS matching_author
FROM diaries d LEFT JOIN couples c ON c.id=d.couple_id
GROUP BY d.couple_id,c.status
UNION ALL
SELECT 'albums', a.couple_id, c.status, COUNT(*),
SUM(CASE WHEN a.created_by IN (c.user1_id,c.user2_id) THEN 1 ELSE 0 END)
FROM albums a LEFT JOIN couples c ON c.id=a.couple_id
GROUP BY a.couple_id,c.status
UNION ALL
SELECT 'timeline', t.couple_id, c.status, COUNT(*),
SUM(CASE WHEN t.created_by IN (c.user1_id,c.user2_id) THEN 1 ELSE 0 END)
FROM timeline t LEFT JOIN couples c ON c.id=t.couple_id
GROUP BY t.couple_id,c.status
UNION ALL
SELECT 'checkins', k.couple_id, c.status, COUNT(*),
SUM(CASE WHEN k.created_by IN (c.user1_id,c.user2_id) THEN 1 ELSE 0 END)
FROM checkins k LEFT JOIN couples c ON c.id=k.couple_id
GROUP BY k.couple_id,c.status;
+11
View File
@@ -26,3 +26,14 @@ location ^~ /api/ {
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme; # proxy_set_header X-Forwarded-Proto $scheme;
# } # }
# 上传文件继续转发 Go;本版恢复原静态文件访问,不需要签名。
location ^~ /backend/uploads/ {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
add_header Cache-Control "private, no-store" always;
}
# 避免不存在的旧路径被其他静态规则处理。
location ^~ /uploads/ { return 403; }
+19 -17
View File
@@ -497,7 +497,7 @@ func adminDeleteUser(c *httpapi.Ctx) error {
} }
var unbindStatusLabels = map[string]string{ var unbindStatusLabels = map[string]string{
"pending": "待回应", "agreed": "已同意(已解绑)", "rejected": "已拒绝", "cancelled": "已撤销", "expired": "已过期", "pending": "待对方回应", "cooling": "冷静期", "completed": "解绑完成", "rejected": "已拒绝", "cancelled": "已撤销", "expired": "已过期",
} }
func adminUnbindRequests(c *httpapi.Ctx) error { func adminUnbindRequests(c *httpapi.Ctx) error {
@@ -506,20 +506,22 @@ func adminUnbindRequests(c *httpapi.Ctx) error {
} }
rs, err := c.DB.Query(` rs, err := c.DB.Query(`
SELECT SELECT
ur.id, ur.couple_id, ur.status, ur.created_at, ur.responded_at, ur.id, ur.couple_id, ur.status, ur.created_at, ur.responded_at, ur.cooling_until, ur.completed_at, ur.cancelled_at,
cancel_user.nickname AS cancelled_by_name,
r.id as requester_id, r.user_id as requester_uid, r.nickname as requester_name, r.avatar as requester_avatar, r.id as requester_id, r.user_id as requester_uid, r.nickname as requester_name, r.avatar as requester_avatar,
c.couple_space_id, c.data_deleted, c.status as couple_status, c.couple_space_id, c.data_deleted, c.status as couple_status,
u1.nickname as user1_name, u1.user_id as user1_uid, u1.nickname as user1_name, u1.user_id as user1_uid,
u2.nickname as user2_name, u2.user_id as user2_uid u2.nickname as user2_name, u2.user_id as user2_uid
FROM unbind_requests ur FROM unbind_requests ur
JOIN users r ON ur.requester_id = r.id JOIN users r ON ur.requester_id = r.id
LEFT JOIN users cancel_user ON ur.cancelled_by = cancel_user.id
JOIN couples c ON ur.couple_id = c.id JOIN couples c ON ur.couple_id = c.id
LEFT JOIN users u1 ON c.user1_id = u1.id LEFT JOIN users u1 ON c.user1_id = u1.id
LEFT JOIN users u2 ON c.user2_id = u2.id LEFT JOIN users u2 ON c.user2_id = u2.id
WHERE NOT (c.status = 'active' AND ur.status = 'agreed') WHERE NOT (c.status = 'active' AND ur.status = 'agreed')
UNION UNION
SELECT SELECT
NULL as id, c.id as couple_id, 'agreed' as status, NULL as created_at, NULL as responded_at, NULL as id, c.id as couple_id, 'completed' as status, NULL as created_at, NULL as responded_at, NULL as cooling_until, NULL as completed_at, NULL as cancelled_at, NULL as cancelled_by_name,
NULL as requester_id, NULL as requester_uid, NULL as requester_name, NULL as requester_avatar, NULL as requester_id, NULL as requester_uid, NULL as requester_name, NULL as requester_avatar,
c.couple_space_id, c.data_deleted, c.status as couple_status, c.couple_space_id, c.data_deleted, c.status as couple_status,
u1.nickname as user1_name, u1.user_id as user1_uid, u1.nickname as user1_name, u1.user_id as user1_uid,
@@ -575,12 +577,17 @@ func adminForceUnbind(c *httpapi.Ctx) error {
if couple == nil { if couple == nil {
return c.Err(404, "情侣不存在") return c.Err(404, "情侣不存在")
} }
if req, _ := c.DB.QueryOne("SELECT id, requester_id, responded_at FROM unbind_requests WHERE couple_id = ? AND status = 'agreed' ORDER BY id DESC LIMIT 1", cid); req == nil { if err := c.DB.AdvanceUnbindings(); err != nil {
return c.Err(400, "双方尚未同意解绑,无法执行操作。请先让双方在微信小程序中完成解绑确认") return err
} }
users, _ := c.DB.Query("SELECT id, user_id, nickname FROM users WHERE couple_id = ?", cid) current, err := c.DB.QueryOne("SELECT status FROM couples WHERE id=?", cid)
softUnbind(c.DB, cid) if err != nil {
return c.OK(M{"success": true, "msg": "已执行解绑操作(双方已同意),情侣「" + coupleUserNames(users) + "」的关系已解除,所有数据已保留"}) return err
}
if current.Str("status") != "unbound" {
return c.Err(409, "需双方同意并等待72小时冷静期结束,不能提前解绑")
}
return c.OK(M{"success": true, "msg": "冷静期已结束,系统已自动解绑并处理共同数据"})
} }
func adminRestoreCouple(c *httpapi.Ctx) error { func adminRestoreCouple(c *httpapi.Ctx) error {
@@ -595,15 +602,7 @@ func adminRestoreCouple(c *httpapi.Ctx) error {
if couple == nil { if couple == nil {
return c.Err(400, "该情侣记录不存在或数据已被删除,无法恢复") return c.Err(400, "该情侣记录不存在或数据已被删除,无法恢复")
} }
u1, u2 := couple.Int("user1_id"), couple.Int("user2_id") return c.Err(409, "旧空间已封存,恢复关系请由双方重新发起绑定,后台不能覆盖当前关系")
users, _ := c.DB.Query("SELECT id, user_id, nickname FROM users WHERE id IN (?, ?)", u1, u2)
if len(users) < 2 {
return c.Err(400, "用户数据异常,无法恢复情侣关系")
}
c.DB.Exec("UPDATE couples SET status = 'active' WHERE id = ?", cid)
c.DB.Exec("UPDATE users SET couple_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id IN (?, ?)", cid, u1, u2)
c.DB.Exec("DELETE FROM unbind_requests WHERE couple_id = ?", cid)
return c.OK(M{"success": true, "msg": "已恢复情侣关系「" + coupleUserNames(users) + "」,所有数据已恢复"})
} }
func adminDeleteCoupleData(c *httpapi.Ctx) error { func adminDeleteCoupleData(c *httpapi.Ctx) error {
@@ -618,6 +617,9 @@ func adminDeleteCoupleData(c *httpapi.Ctx) error {
if couple == nil { if couple == nil {
return c.Err(400, "该情侣记录不存在或状态异常,无法删除数据") return c.Err(400, "该情侣记录不存在或状态异常,无法删除数据")
} }
if couple.Int("data_deleted") == 1 {
return c.OK(M{"success": true, "msg": "共同数据已删除,解绑记录保留"})
}
users, _ := c.DB.Query("SELECT id, user_id, nickname FROM users WHERE id IN (?, ?)", couple.Int("user1_id"), couple.Int("user2_id")) users, _ := c.DB.Query("SELECT id, user_id, nickname FROM users WHERE id IN (?, ?)", couple.Int("user1_id"), couple.Int("user2_id"))
c.DB.Exec("UPDATE couples SET data_deleted = 1 WHERE id = ?", cid) c.DB.Exec("UPDATE couples SET data_deleted = 1 WHERE id = ?", cid)
executeUnbind(c.DB, cid) executeUnbind(c.DB, cid)
+74 -5
View File
@@ -1,8 +1,11 @@
package handler package handler
import ( import (
"errors"
"fmt" "fmt"
"strings" "strings"
"time"
"unicode/utf8"
"past/internal/httpapi" "past/internal/httpapi"
"past/internal/store" "past/internal/store"
@@ -19,6 +22,7 @@ func registerAlbum(r *httpapi.Router) {
r.Handle("photo_upload", photoUpload) r.Handle("photo_upload", photoUpload)
r.Handle("photo_delete", photoDelete) r.Handle("photo_delete", photoDelete)
r.Handle("photo_add", photoAdd) r.Handle("photo_add", photoAdd)
r.Handle("photo_update", photoUpdate)
} }
// coupleSpaceID 对应 SELECT couple_space_id FROM couples WHERE id = ? // coupleSpaceID 对应 SELECT couple_space_id FROM couples WHERE id = ?
@@ -321,11 +325,7 @@ func photoAdd(c *httpapi.Ctx) error {
if err := checkTextOr400(c, strings.TrimSpace(c.InStr("description")+" "+c.InStr("activity"))); err != nil { if err := checkTextOr400(c, strings.TrimSpace(c.InStr("description")+" "+c.InStr("activity"))); err != nil {
return err return err
} }
lat, lng := c.InFloat("lat"), c.InFloat("lng") location, lat, lng := photoLocation(c)
location := c.InTrim("location")
if lat != 0 && lng != 0 && location == "" {
location = c.App.Geo.ReverseGeocode(lat, lng)
}
path := c.In("path") path := c.In("path")
id, err := c.DB.Insert("INSERT INTO photos (album_id, path, description, taken_at, location, lat, lng, activity, batch_id, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", id, err := c.DB.Insert("INSERT INTO photos (album_id, path, description, taken_at, location, lat, lng, activity, batch_id, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
albumID, path, inOr(c, "description", ""), inOr(c, "taken_at", ""), location, lat, lng, inOr(c, "activity", ""), inOr(c, "batch_id", ""), uid) albumID, path, inOr(c, "description", ""), inOr(c, "taken_at", ""), location, lat, lng, inOr(c, "activity", ""), inOr(c, "batch_id", ""), uid)
@@ -344,3 +344,72 @@ func photoAdd(c *httpapi.Ctx) error {
} }
return c.OK(M{"success": true, "id": fmt.Sprint(id), "path": path}) return c.OK(M{"success": true, "id": fmt.Sprint(id), "path": path})
} }
// photoUpdate 仅修改照片信息,权限范围与照片列表保持一致。
func photoUpdate(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
ids, err := requireCoupleUserIds(c)
if err != nil {
return err
}
id := c.InInt("id")
if id <= 0 || !c.InHas("taken_at") || !c.InHas("location") || !c.InHas("activity") {
return c.Err(400, "参数不完整")
}
location, lat, lng := photoLocation(c)
takenAt, activity := c.InTrim("taken_at"), c.InTrim("activity")
if err := validatePhotoMetadata(takenAt, location, activity); err != nil {
return c.Err(400, err.Error())
}
photo, err := c.DB.QueryOne("SELECT p.id FROM photos p JOIN albums a ON p.album_id = a.id WHERE p.id = ? AND a.created_by IN (?, ?)", id, ids[0], ids[1])
if err != nil {
return err
}
if photo == nil {
return c.Err(404, "照片不存在或无权编辑")
}
if err := checkTextOr400(c, strings.TrimSpace(location+" "+activity)); err != nil {
return err
}
_, err = c.DB.Exec("UPDATE photos p JOIN albums a ON p.album_id = a.id SET p.taken_at = ?, p.location = ?, p.activity = ?, p.lat = ?, p.lng = ? WHERE p.id = ? AND a.created_by IN (?, ?)", takenAt, location, activity, lat, lng, id, ids[0], ids[1])
if err != nil {
return err
}
return c.OK(M{"success": true, "id": id, "taken_at": takenAt, "location": location, "activity": activity})
}
func validatePhotoMetadata(takenAt, location, activity string) error {
if takenAt != "" {
date, err := time.Parse("2006-01-02", takenAt)
if err != nil || date.Format("2006-01-02") != takenAt {
return errors.New("拍摄时间格式不正确")
}
}
if utf8.RuneCountInString(location) > 200 {
return errors.New("地点不能超过200字")
}
if utf8.RuneCountInString(activity) > 200 {
return errors.New("在做什么不能超过200字")
}
return nil
}
// 用户填写的地址优先;留空时定位解析,最后使用当前用户保存的地址。
func photoLocation(c *httpapi.Ctx) (string, float64, float64) {
location := c.InTrim("location")
lat, lng := c.InFloat("lat"), c.InFloat("lng")
if location != "" {
return location, lat, lng
}
if lat != 0 && lng != 0 {
location = c.App.Geo.ReverseGeocode(lat, lng)
}
if location == "" {
if user, err := c.DB.QueryOne("SELECT location FROM users WHERE id = ?", c.UID()); err == nil && user != nil {
location = strings.TrimSpace(user.Str("location"))
}
}
return location, lat, lng
}
+28
View File
@@ -0,0 +1,28 @@
package handler
import (
"strings"
"testing"
)
func TestValidatePhotoMetadata(t *testing.T) {
for _, tt := range []struct {
name, date, location, activity string
valid bool
}{
{"clear fields", "", "", "", true},
{"leap day", "2024-02-29", "北京颐和园", "第一次一起看日落", true},
{"invalid day", "2026-02-29", "", "", false},
{"date format", "2026-2-1", "", "", false},
{"time not accepted", "2026-10-07 09:54", "", "", false},
{"unicode at limit", "", strings.Repeat("景", 200), strings.Repeat("爱", 200), true},
{"location too long", "", strings.Repeat("景", 201), "", false},
{"story too long", "", "", strings.Repeat("爱", 201), false},
} {
t.Run(tt.name, func(t *testing.T) {
if got := validatePhotoMetadata(tt.date, tt.location, tt.activity); (got == nil) != tt.valid {
t.Fatalf("valid=%v, error=%v", tt.valid, got)
}
})
}
}
+23 -8
View File
@@ -17,6 +17,8 @@ func registerCheckin(r *httpapi.Router) {
r.Handle("checkin_stats", checkinStats) r.Handle("checkin_stats", checkinStats)
r.Handle("checkin_toggle", checkinToggle) r.Handle("checkin_toggle", checkinToggle)
r.Handle("checkin_delete", checkinDelete) r.Handle("checkin_delete", checkinDelete)
r.Handle("smoking_info", smokingInfo)
r.Handle("smoking_action", smokingAction)
} }
// weekMonday 对应 date('Y-m-d', strtotime('monday this week')):ISO 周,周日归上一个周一 // weekMonday 对应 date('Y-m-d', strtotime('monday this week')):ISO 周,周日归上一个周一
@@ -157,14 +159,14 @@ func checkinList(c *httpapi.Ctx) error {
return c.OK(M{"items": demoData("checkin"), "week_both_count": 0, "total": 5}) return c.OK(M{"items": demoData("checkin"), "week_both_count": 0, "total": 5})
} }
uid := c.UID() uid := c.UID()
items, err := c.DB.Query("SELECT c.*, u.nickname as creator_name, u.avatar as creator_avatar FROM checkins c LEFT JOIN users u ON c.created_by = u.id WHERE c.created_by IN (?, ?) ORDER BY c.sort_order ASC, c.created_at DESC", ids[0], ids[1]) items, err := c.DB.Query("SELECT c.*, u.nickname as creator_name, u.avatar as creator_avatar FROM checkins c LEFT JOIN users u ON c.created_by = u.id WHERE c.created_by IN (?, ?) AND NOT EXISTS (SELECT 1 FROM smoking_accounts sa WHERE sa.checkin_id = c.id) ORDER BY c.sort_order ASC, c.created_at DESC", ids[0], ids[1])
if err != nil { if err != nil {
return err return err
} }
for _, it := range items { for _, it := range items {
decorateCheckin(it, uid, true) decorateCheckin(it, uid, true)
} }
total := countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?)", ids[0], ids[1]) total := countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND NOT EXISTS (SELECT 1 FROM smoking_accounts sa WHERE sa.checkin_id = checkins.id)", ids[0], ids[1])
var logTotal int64 var logTotal int64
if cid := c.CoupleID(); cid != 0 { if cid := c.CoupleID(); cid != 0 {
logTotal = countC(c, "SELECT COUNT(*) as c FROM checkin_logs WHERE couple_id = ?", cid) logTotal = countC(c, "SELECT COUNT(*) as c FROM checkin_logs WHERE couple_id = ?", cid)
@@ -215,6 +217,9 @@ func loadCheckinItem(c *httpapi.Ctx, id int64) (store.Row, error) {
if item == nil { if item == nil {
return nil, c.Err(404, "打卡项不存在") return nil, c.Err(404, "打卡项不存在")
} }
if err := guardSmokingCheckin(c, id); err != nil {
return nil, err
}
return item, nil return item, nil
} }
@@ -374,15 +379,19 @@ func checkinDetail(c *httpapi.Ctx) error {
func checkinStats(c *httpapi.Ctx) error { func checkinStats(c *httpapi.Ctx) error {
ids := coupleUserIds(c, 0) ids := coupleUserIds(c, 0)
if len(ids) == 0 { if len(ids) == 0 {
return c.OK([]any{}) ids = []int64{c.UID(), 0}
} }
td, ws := today(), weekMonday() td, ws := today(), weekMonday()
uid, partner := c.UID(), partnerUserId(c.DB, c.UID())
smokeCount := func(owner int64, since string) int64 {
return countC(c, "SELECT COUNT(*) as c FROM smoking_entries WHERE user_id = ? AND kind IN ('consume','receive_smoke') AND undone = 0 AND created_at >= ?", owner, since)
}
return c.OK(M{ return c.OK(M{
"week_my_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND my_checkin_time >= ?", ids[0], ids[1], ws), "week_my_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND id NOT IN (SELECT checkin_id FROM smoking_accounts WHERE checkin_id IS NOT NULL) AND my_checkin_time >= ?", ids[0], ids[1], ws) + smokeCount(uid, ws),
"week_partner_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND partner_checkin_time >= ?", ids[0], ids[1], ws), "week_partner_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND id NOT IN (SELECT checkin_id FROM smoking_accounts WHERE checkin_id IS NOT NULL) AND partner_checkin_time >= ?", ids[0], ids[1], ws) + smokeCount(partner, ws),
"week_both_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND my_checkin_time >= ? AND partner_checkin_time >= ?", ids[0], ids[1], ws, ws), "week_both_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND id NOT IN (SELECT checkin_id FROM smoking_accounts WHERE checkin_id IS NOT NULL) AND my_checkin_time >= ? AND partner_checkin_time >= ?", ids[0], ids[1], ws, ws),
"today_my_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND my_checkin_time LIKE ?", ids[0], ids[1], td+"%"), "today_my_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND id NOT IN (SELECT checkin_id FROM smoking_accounts WHERE checkin_id IS NOT NULL) AND my_checkin_time LIKE ?", ids[0], ids[1], td+"%") + smokeCount(uid, td),
"today_partner_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND partner_checkin_time LIKE ?", ids[0], ids[1], td+"%"), "today_partner_count": countC(c, "SELECT COUNT(*) as c FROM checkins WHERE created_by IN (?, ?) AND id NOT IN (SELECT checkin_id FROM smoking_accounts WHERE checkin_id IS NOT NULL) AND partner_checkin_time LIKE ?", ids[0], ids[1], td+"%") + smokeCount(partner, td),
}) })
} }
@@ -395,6 +404,9 @@ func checkinToggle(c *httpapi.Ctx) error {
return err return err
} }
id := c.In("id") id := c.In("id")
if err := guardSmokingCheckin(c, c.InInt("id")); err != nil {
return err
}
cur, _ := c.DB.QueryOne("SELECT is_completed FROM checkins WHERE id = ? AND couple_id = ?", id, cid) cur, _ := c.DB.QueryOne("SELECT is_completed FROM checkins WHERE id = ? AND couple_id = ?", id, cid)
newStatus := int64(1) newStatus := int64(1)
if cur != nil && store.Truthy(cur["is_completed"]) { if cur != nil && store.Truthy(cur["is_completed"]) {
@@ -428,6 +440,9 @@ func checkinDelete(c *httpapi.Ctx) error {
return err return err
} }
id := c.In("id") id := c.In("id")
if err := guardSmokingCheckin(c, c.InInt("id")); err != nil {
return err
}
c.DB.Exec("DELETE FROM checkin_logs WHERE checkin_id = ?", id) c.DB.Exec("DELETE FROM checkin_logs WHERE checkin_id = ?", id)
if _, err := c.DB.Exec("DELETE FROM checkins WHERE id = ? AND couple_id = ?", id, cid); err != nil { if _, err := c.DB.Exec("DELETE FROM checkins WHERE id = ? AND couple_id = ?", id, cid); err != nil {
return err return err
-7
View File
@@ -144,13 +144,6 @@ func userOpenId(db store.Querier, uid int64) string {
return row.Str("openid") return row.Str("openid")
} }
// softUnbind 对应 softUnbind($db, $cid):仅标记解除,数据保留
func softUnbind(db store.Querier, cid int64) {
db.Exec("UPDATE unbind_requests SET status = 'agreed', responded_at = NOW() WHERE couple_id = ? AND status IN ('pending','agreed')", cid)
db.Exec("UPDATE couples SET status = 'unbound', data_deleted = 0 WHERE id = ?", cid)
db.Exec("UPDATE users SET couple_id = NULL, updated_at = CURRENT_TIMESTAMP WHERE couple_id = ?", cid)
}
// executeUnbind 对应 executeUnbind($db, $cid):删除全部情侣数据与文件,仅保留用户资料 // executeUnbind 对应 executeUnbind($db, $cid):删除全部情侣数据与文件,仅保留用户资料
func executeUnbind(db store.Querier, cid int64) { func executeUnbind(db store.Querier, cid int64) {
db.Exec("DELETE FROM timeline WHERE couple_id = ?", cid) db.Exec("DELETE FROM timeline WHERE couple_id = ?", cid)
+118 -113
View File
@@ -12,7 +12,7 @@ import (
"past/internal/store" "past/internal/store"
) )
var targetIDRe = regexp.MustCompile(`^520\d{3,}$`) var targetIDRe = regexp.MustCompile(`^[0-9]{6}$`)
func registerCouple(r *httpapi.Router) { func registerCouple(r *httpapi.Router) {
r.Handle("generate_invite_link", generateInviteLink) r.Handle("generate_invite_link", generateInviteLink)
@@ -437,154 +437,159 @@ func userCoupleID(c *httpapi.Ctx, uid int64) int64 {
return u.Int("couple_id") return u.Int("couple_id")
} }
// withBoundCouple serializes all lifecycle transitions with the expiry worker.
func withBoundCouple(c *httpapi.Ctx, next func(*store.Tx, int64) error) error {
cid := c.CoupleID()
if cid == 0 {
return c.Err(400, "未绑定情侣")
}
tx, err := c.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
cp, err := tx.QueryOne("SELECT id,status FROM couples WHERE id=? FOR UPDATE", cid)
if err != nil {
return err
}
member, err := tx.QueryOne("SELECT id FROM users WHERE id=? AND couple_id=?", c.UID(), cid)
if err != nil {
return err
}
if cp == nil || cp.Str("status") != "active" || member == nil {
return c.Err(409, "情侣关系已变化,请刷新")
}
if err := next(tx, cid); err != nil {
return err
}
return tx.Commit()
}
func unbindRequest(c *httpapi.Ctx) error { func unbindRequest(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil { if err := c.RequirePOST(); err != nil {
return err return err
} }
uid := c.UID() err := withBoundCouple(c, func(tx *store.Tx, cid int64) error {
cid := userCoupleID(c, uid) if _, err := tx.Exec("UPDATE unbind_requests SET status='expired',responded_at=NOW() WHERE couple_id=? AND status='pending' AND created_at<=DATE_SUB(NOW(),INTERVAL 72 HOUR)", cid); err != nil {
if cid == 0 { return err
return c.Err(400, "未绑定情侣")
}
existing, _ := c.DB.QueryOne("SELECT id, status, requester_id, created_at FROM unbind_requests WHERE couple_id = ? AND status IN ('pending','agreed') ORDER BY id DESC LIMIT 1", cid)
if existing != nil {
if existing.Str("status") == "pending" {
return c.Err(400, "已有待处理的解绑请求,请等待对方回应")
} }
if existing.Str("status") == "agreed" { existing, err := tx.QueryOne("SELECT id FROM unbind_requests WHERE couple_id=? AND status IN ('pending','cooling') FOR UPDATE", cid)
softUnbind(c.DB, cid) if err != nil {
return c.OK(M{"success": true, "msg": "对方已同意解绑,情侣关系已解除,所有数据已保留"}) return err
} }
return nil if existing != nil {
} return c.Err(409, "已有解绑申请或正在冷静期")
if _, err := c.DB.Exec("INSERT INTO unbind_requests (couple_id, requester_id, status, created_at) VALUES (?, ?, 'pending', NOW())", cid, uid); err != nil { }
_, err = tx.Exec("INSERT INTO unbind_requests (couple_id,requester_id,status) VALUES (?,?,'pending')", cid, c.UID())
return err
})
if err != nil {
return err return err
} }
logActivity(c.DB, cid, uid, "unbind_request", "发起了解除情侣绑定请求", "") return c.OK(M{"success": true, "msg": "请求已发送,对方同意后进入72小时冷静期"})
return c.OK(M{"success": true, "msg": "解绑请求已发送,等待对方确认(72小时内有效)"})
} }
func unbindRespond(c *httpapi.Ctx) error { func unbindRespond(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil { if err := c.RequirePOST(); err != nil {
return err return err
} }
uid := c.UID()
// $input = json_decode(...) ?: $_POST
agree := c.InBool("agree") agree := c.InBool("agree")
if len(c.Input()) == 0 { err := withBoundCouple(c, func(tx *store.Tx, cid int64) error {
agree = store.Truthy(c.Form("agree")) req, err := tx.QueryOne("SELECT id FROM unbind_requests WHERE couple_id=? AND status='pending' AND requester_id!=? AND created_at>DATE_SUB(NOW(),INTERVAL 72 HOUR) FOR UPDATE", cid, c.UID())
if err != nil {
return err
}
if req == nil {
return c.Err(409, "没有有效的待确认申请")
}
if agree {
_, err = tx.Exec("UPDATE unbind_requests SET status='cooling',responded_at=NOW(),cooling_until=DATE_ADD(NOW(),INTERVAL 72 HOUR) WHERE id=?", req.Int("id"))
} else {
_, err = tx.Exec("UPDATE unbind_requests SET status='rejected',responded_at=NOW() WHERE id=?", req.Int("id"))
}
return err
})
if err != nil {
return err
} }
cid := userCoupleID(c, uid) return c.OK(M{"success": true, "msg": "申请已处理,双方同意后进入72小时冷静期"})
if cid == 0 {
return c.Err(400, "未绑定情侣")
}
req, _ := c.DB.QueryOne("SELECT id, requester_id, created_at FROM unbind_requests WHERE couple_id = ? AND status = 'pending' AND requester_id != ? ORDER BY id DESC LIMIT 1", cid, uid)
if req == nil {
return c.Err(400, "没有待处理的解绑请求")
}
if created, err := parseDate(req.Str("created_at")); err == nil && time.Since(created) > 72*time.Hour {
c.DB.Exec("UPDATE unbind_requests SET status = 'expired', responded_at = NOW() WHERE id = ?", req.Int("id"))
return c.Err(400, "解绑请求已超过72小时,已自动取消")
}
if agree {
c.DB.Exec("UPDATE unbind_requests SET status = 'agreed', responded_at = NOW() WHERE id = ?", req.Int("id"))
logActivity(c.DB, cid, uid, "unbind_agreed", "同意了解除情侣绑定请求", "")
softUnbind(c.DB, cid)
return c.OK(M{"success": true, "msg": "已同意解绑,情侣关系已解除,所有数据已保留"})
}
c.DB.Exec("UPDATE unbind_requests SET status = 'rejected', responded_at = NOW() WHERE id = ?", req.Int("id"))
logActivity(c.DB, cid, uid, "unbind_rejected", "拒绝了解除情侣绑定请求", "")
return c.OK(M{"success": true, "msg": "已拒绝解绑请求"})
} }
func unbindCancel(c *httpapi.Ctx) error { func unbindCancel(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil { if err := c.RequirePOST(); err != nil {
return err return err
} }
uid := c.UID() err := withBoundCouple(c, func(tx *store.Tx, cid int64) error {
cid := userCoupleID(c, uid) req, err := tx.QueryOne("SELECT id FROM unbind_requests WHERE couple_id=? AND ((status='pending' AND requester_id=? AND created_at>DATE_SUB(NOW(),INTERVAL 72 HOUR)) OR (status='cooling' AND cooling_until>NOW())) ORDER BY id DESC LIMIT 1 FOR UPDATE", cid, c.UID())
if cid == 0 { if err != nil {
return c.Err(400, "未绑定情侣") return err
}
req, _ := c.DB.QueryOne("SELECT id, status FROM unbind_requests WHERE couple_id = ? AND requester_id = ? AND status IN ('pending','agreed') ORDER BY id DESC LIMIT 1", cid, uid)
if req == nil {
return c.Err(400, "没有可撤销的解绑请求")
}
c.DB.Exec("UPDATE unbind_requests SET status = 'cancelled', responded_at = NOW() WHERE id = ?", req.Int("id"))
logActivity(c.DB, cid, uid, "unbind_cancelled", "撤销了解除情侣绑定请求", "")
return c.OK(M{"success": true, "msg": "已撤销解绑请求"})
}
func unbindStatus(c *httpapi.Ctx) error {
uid := c.UID()
// $cid 来源不同类型不同:来自 users 行为字符串;来自 unbind_requests 反查时经过 intval 为整数
var cidVal any
u, _ := c.DB.QueryOne("SELECT couple_id FROM users WHERE id = ?", uid)
cid := int64(0)
if u != nil && u.Int("couple_id") != 0 {
cid, cidVal = u.Int("couple_id"), u["couple_id"]
}
if cid == 0 {
found, _ := c.DB.QueryOne("SELECT ur.couple_id FROM unbind_requests ur WHERE ur.requester_id = ? OR ur.id IN (SELECT ur2.id FROM unbind_requests ur2 JOIN couples c ON ur2.couple_id = c.id WHERE (c.user1_id = ? OR c.user2_id = ?)) ORDER BY ur.id DESC LIMIT 1", uid, uid, uid)
if found != nil {
cid, cidVal = found.Int("couple_id"), found.Int("couple_id")
} }
} if req == nil {
if cid == 0 { return c.Err(409, "没有可取消的申请,或冷静期已结束")
return c.OK(M{"status": "none", "msg": "未绑定情侣"}) }
} _, err = tx.Exec("UPDATE unbind_requests SET status='cancelled',cancelled_at=NOW(),cancelled_by=? WHERE id=?", c.UID(), req.Int("id"))
req, err := c.DB.QueryOne("SELECT id, requester_id, status, created_at, responded_at FROM unbind_requests WHERE couple_id = ? ORDER BY id DESC LIMIT 1", cid) return err
})
if err != nil { if err != nil {
return err return err
} }
dataDeleted := int64(0) return c.OK(M{"success": true, "msg": "已取消解绑,关系继续保留"})
if cd, _ := c.DB.QueryOne("SELECT data_deleted FROM couples WHERE id = ?", cid); cd != nil { }
dataDeleted = cd.Int("data_deleted") func unbindStatus(c *httpapi.Ctx) error {
if err := c.DB.AdvanceUnbindings(); err != nil {
return err
}
cid := c.CoupleID()
uid := c.UID()
if cid == 0 {
cp, err := c.DB.QueryOne("SELECT id FROM couples WHERE user1_id=? OR user2_id=? ORDER BY id DESC LIMIT 1", uid, uid)
if err != nil {
return err
}
if cp != nil {
cid = cp.Int("id")
}
}
if cid == 0 {
return c.OK(M{"status": "none", "couple_id": nil})
}
if _, err := c.DB.Exec("UPDATE unbind_requests SET status='expired',responded_at=NOW() WHERE couple_id=? AND status='pending' AND created_at<=DATE_SUB(NOW(),INTERVAL 72 HOUR)", cid); err != nil {
return err
}
req, err := c.DB.QueryOne("SELECT id,requester_id,status,created_at,responded_at,cooling_until,completed_at,cancelled_at,cancelled_by,GREATEST(0,TIMESTAMPDIFF(SECOND,NOW(),CASE WHEN status='cooling' THEN cooling_until ELSE DATE_ADD(created_at,INTERVAL 72 HOUR) END)) AS remaining_seconds FROM unbind_requests WHERE couple_id=? ORDER BY id DESC LIMIT 1", cid)
if err != nil {
return err
} }
if req == nil { if req == nil {
return c.OK(M{"status": "none", "msg": "无解绑请求", "couple_id": cidVal, "data_deleted": dataDeleted}) return c.OK(M{"status": "none", "couple_id": cid})
}
if req.Str("status") == "pending" {
created, _ := parseDate(req.Str("created_at"))
elapsed := int64(time.Since(created).Seconds())
remaining := int64(72*3600) - elapsed
if remaining < 0 {
remaining = 0
}
if elapsed > 72*3600 {
c.DB.Exec("UPDATE unbind_requests SET status = 'expired', responded_at = NOW() WHERE id = ?", req.Int("id"))
req["status"] = "expired"
} else {
req["remaining_seconds"] = remaining
}
} }
req["is_requester"] = req.Int("requester_id") == uid req["is_requester"] = req.Int("requester_id") == uid
requester, _ := c.DB.QueryOne("SELECT nickname, username FROM users WHERE id = ?", req.Int("requester_id")) req["couple_id"] = cid
name := "未知" req["data_deleted"] = 0
if requester != nil { if req.Str("status") == "completed" {
name = requester.Str("nickname") cp, err := c.DB.QueryOne("SELECT data_deleted FROM couples WHERE id = ?", cid)
if name == "" { if err != nil {
name = requester.Str("username") return err
}
if cp != nil {
req["data_deleted"] = cp.Int("data_deleted")
} }
} }
req["requester_name"] = name owner, err := c.DB.QueryOne("SELECT nickname FROM users WHERE id=?", req.Int("requester_id"))
req["couple_id"] = cid if err != nil {
req["data_deleted"] = dataDeleted return err
}
if owner != nil {
req["requester_name"] = owner.Str("nickname")
}
return c.OK(req) return c.OK(req)
} }
func unbindCouple(c *httpapi.Ctx) error { func unbindCouple(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil { if err := c.RequirePOST(); err != nil {
return err return err
} }
uid := c.UID() if err := c.DB.AdvanceUnbindings(); err != nil {
cid := userCoupleID(c, uid) return err
if cid == 0 {
return c.Err(400, "未绑定情侣")
} }
if ag, _ := c.DB.QueryOne("SELECT id FROM unbind_requests WHERE couple_id = ? AND status = 'agreed' ORDER BY id DESC LIMIT 1", cid); ag == nil { if c.CoupleID() != 0 {
return c.Err(400, "没有已确认的解绑请求,请先发起解绑") return c.Err(409, "冷静期结束后将自动解绑,不能提前解除")
} }
softUnbind(c.DB, cid) return c.OK(M{"success": true})
return c.OK(M{"success": true, "msg": "已解除绑定"})
} }
@@ -0,0 +1,16 @@
package handler
import "testing"
func TestInvitationUserIDFormat(t *testing.T) {
for _, id := range []string{"520087", "521685", "523019", "999999"} {
if !targetIDRe.MatchString(id) {
t.Errorf("valid six-digit ID rejected: %s", id)
}
}
for _, id := range []string{"", "52301", "5230190", "52301a", "523019 ", "523019"} {
if targetIDRe.MatchString(id) {
t.Errorf("invalid ID accepted: %q", id)
}
}
}
+3
View File
@@ -38,6 +38,9 @@ func uploadGeneric(c *httpapi.Ctx) error {
if c.FormHas("sub_dir") { if c.FormHas("sub_dir") {
subDir = c.Form("sub_dir") subDir = c.Form("sub_dir")
} }
if strings.ContainsAny(subDir, "/\\.") || subDir == "" {
return c.Err(400, "无效的上传目录")
}
uid := c.UID() uid := c.UID()
if uid == 0 { if uid == 0 {
return c.ErrCode(401, "请先登录", upload.ErrNotLoggedIn) return c.ErrCode(401, "请先登录", upload.ErrNotLoggedIn)
@@ -0,0 +1,128 @@
package handler
import (
"database/sql/driver"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"past/internal/config"
"past/internal/httpapi"
"past/internal/store"
"past/internal/testdb"
)
func permissionCtx(t *testing.T, method, uri, body string, steps []testdb.Step) (*httpapi.Ctx, *httptest.ResponseRecorder, *testdb.Script) {
t.Helper()
db, s := testdb.Open(t, steps)
out := httptest.NewRecorder()
g, _ := gin.CreateTestContext(out)
g.Request = httptest.NewRequest(method, uri, strings.NewReader(body))
g.Request.Header.Set("Authorization", "Bearer test")
g.Request.Header.Set("Content-Type", "application/json")
app := &httpapi.App{DB: &store.DB{DB: db}, Cfg: &config.Config{}}
return &httpapi.Ctx{G: g, R: g.Request, W: out, DB: app.DB, App: app, Method: method}, out, s
}
func uidStep(uid int64) testdb.Step {
return testdb.Step{Match: "FROM user_tokens", Columns: []string{"user_id"}, Rows: [][]driver.Value{{uid}}}
}
func cidStep(cid int64) testdb.Step {
return testdb.Step{Match: "SELECT couple_id FROM users WHERE id = ?", Columns: []string{"couple_id"}, Rows: [][]driver.Value{{cid}}}
}
func TestAgreeStartsCoolingWithoutUnbinding(t *testing.T) {
c, out, s := permissionCtx(t, "POST", "/api/unbindRespond", `{"agree":true}`, []testdb.Step{
uidStep(2), cidStep(10),
{Match: "FROM couples WHERE id=? FOR UPDATE", Columns: []string{"id", "status"}, Rows: [][]driver.Value{{int64(10), "active"}}},
{Match: "FROM users WHERE id=? AND couple_id=?", Columns: []string{"id"}, Rows: [][]driver.Value{{int64(2)}}},
{Match: "status='pending' AND requester_id!=?", Columns: []string{"id"}, Rows: [][]driver.Value{{int64(8)}}},
{Match: "cooling_until=DATE_ADD(NOW(),INTERVAL 72 HOUR)", Args: []any{int64(8)}},
})
unbindRespond(c)
if out.Code != 200 || s.Commits != 1 {
t.Fatalf("code=%d commits=%d body=%s", out.Code, s.Commits, out.Body.String())
}
}
func TestEitherPartnerCanCancelCoolingButNotAfterDeadline(t *testing.T) {
for _, expired := range []bool{false, true} {
t.Run(map[bool]string{false: "partner cancels", true: "deadline reached"}[expired], func(t *testing.T) {
rows := [][]driver.Value{{int64(8)}}
if expired {
rows = nil
}
steps := []testdb.Step{uidStep(2), cidStep(10), {Match: "FOR UPDATE", Columns: []string{"id", "status"}, Rows: [][]driver.Value{{int64(10), "active"}}}, {Match: "FROM users WHERE id=?", Columns: []string{"id"}, Rows: [][]driver.Value{{int64(2)}}}, {Match: "status='cooling' AND cooling_until>NOW()", Columns: []string{"id"}, Rows: rows}}
if !expired {
steps = append(steps, testdb.Step{Match: "status='cancelled'"})
}
c, out, s := permissionCtx(t, "POST", "/api/unbindCancel", `{}`, steps)
unbindCancel(c)
want := 200
if expired {
want = 409
if s.Commits != 0 {
t.Fatal("expired cancellation committed")
}
}
if out.Code != want {
t.Fatalf("code=%d body=%s", out.Code, out.Body.String())
}
})
}
}
func TestLegacyDetailsStillRequireCurrentMemberAuthors(t *testing.T) {
for _, tt := range []struct {
name, predicate string
handler httpapi.HandlerFunc
}{
{"diary", "d.created_by IN", diaryDetail},
{"timeline", "WHERE id = ? AND created_by IN", timelineDetail},
{"checkin", "c.created_by IN", checkinDetail},
} {
t.Run(tt.name, func(t *testing.T) {
c, out, _ := permissionCtx(t, "GET", "/api/detail?id=9", "", []testdb.Step{uidStep(1), cidStep(22), {Match: "SELECT user1_id, user2_id FROM couples WHERE id = ?", Columns: []string{"user1_id", "user2_id"}, Rows: [][]driver.Value{{int64(1), int64(3)}}}, {Match: tt.predicate, Columns: []string{"id"}}})
tt.handler(c)
if out.Code != 404 {
t.Fatalf("missing or unrelated detail returned %d: %s", out.Code, out.Body.String())
}
})
}
}
func TestLegacyMemberRecordsRemainReadableAcrossHistoricalSpaceIDs(t *testing.T) {
for _, tt := range []struct {
name, predicate string
handler httpapi.HandlerFunc
}{
{"timeline", "FROM timeline WHERE id = ? AND created_by IN", timelineDetail},
{"photos", "WHERE a.created_by IN (?, ?) AND p.album_id = ?", photos},
} {
t.Run(tt.name, func(t *testing.T) {
c, out, _ := permissionCtx(t, "GET", "/api/detail?id=9&album_id=9", "", []testdb.Step{
uidStep(1), cidStep(22),
{Match: "SELECT user1_id, user2_id FROM couples WHERE id = ?", Columns: []string{"user1_id", "user2_id"}, Rows: [][]driver.Value{{int64(1), int64(3)}}},
{Match: tt.predicate, Columns: []string{"id", "couple_id", "created_by", "path"}, Rows: [][]driver.Value{{int64(9), int64(10), int64(1), "uploads/users/1/photos/a.jpg"}}},
})
tt.handler(c)
if out.Code != 200 || !strings.Contains(out.Body.String(), "uploads/users/1/photos/a.jpg") {
t.Fatalf("legacy record disappeared: code=%d body=%s", out.Code, out.Body.String())
}
})
}
}
func TestLegacyPeriodReadsSharedTablesInsteadOfPersonalTables(t *testing.T) {
c, out, _ := permissionCtx(t, "GET", "/api/periodList", "", []testdb.Step{
uidStep(1), cidStep(10),
{Match: "SELECT period_visible FROM users", Columns: []string{"period_visible"}, Rows: [][]driver.Value{{int64(1)}}},
{Match: "SELECT id, period_visible FROM users WHERE couple_id", Columns: []string{"id", "period_visible"}, Rows: [][]driver.Value{{int64(2), int64(1)}}},
{Match: "SELECT user1_id, user2_id, period_source_user_id", Columns: []string{"user1_id", "user2_id", "period_source_user_id"}, Rows: [][]driver.Value{{int64(1), int64(2), int64(2)}}},
{Match: "FROM period_records WHERE couple_id = ?", Args: []any{int64(10)}, Columns: []string{"id", "user_id"}, Rows: [][]driver.Value{{int64(7), int64(2)}}},
{Match: "FROM period_daily_records WHERE couple_id = ?", Args: []any{int64(10)}, Columns: []string{"id"}},
{Match: "FROM period_sex_records WHERE couple_id = ?", Args: []any{int64(10)}, Columns: []string{"id"}},
})
periodList(c)
if out.Code != 200 || !strings.Contains(out.Body.String(), `"user_id":"2"`) {
t.Fatalf("shared period missing: %d %s", out.Code, out.Body.String())
}
}
+232
View File
@@ -0,0 +1,232 @@
package handler
import (
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"unicode/utf8"
"past/internal/httpapi"
)
func guardSmokingCheckin(c *httpapi.Ctx, id int64) error {
row, err := c.DB.QueryOne("SELECT id FROM smoking_accounts WHERE checkin_id = ?", id)
if err != nil {
return err
}
if row != nil {
return c.Err(400, "请通过抽烟记录操作库存和撤销")
}
return nil
}
func smokingDelta(kind string, quantity, stock int64) (int64, error) {
if quantity < 0 || quantity > 1000000 {
return 0, errors.New("数量超出范围")
}
switch kind {
case "consume", "give":
if quantity != 1 {
return 0, errors.New("每次操作一支")
}
if stock < 1 {
return 0, errors.New("库存不足,请先补充库存")
}
return -1, nil
case "receive_smoke":
if quantity != 1 {
return 0, errors.New("每次记录一支")
}
return 0, nil
case "purchase", "receive":
if quantity < 1 || quantity > 10000 {
return 0, errors.New("补充数量应为1到10000支")
}
return quantity, nil
case "adjust":
return quantity - stock, nil
default:
return 0, errors.New("不支持的操作")
}
}
func smokingSnapshot(c *httpapi.Ctx, uid int64) (M, error) {
result := M{"stock": int64(0), "today_count": int64(0), "total_count": int64(0), "logs": []any{}, "daily": []any{}, "last_time": "", "user_id": uid, "read_only": uid != c.UID(), "available": true}
owner, err := c.DB.QueryOne("SELECT nickname, username FROM users WHERE id = ?", uid)
if err != nil {
return nil, err
}
if owner != nil {
result["owner_name"] = owner.Str("nickname")
if owner.Str("nickname") == "" {
result["owner_name"] = owner.Str("username")
}
}
partner := partnerUserId(c.DB, c.UID())
result["partner_id"] = partner
account, err := c.DB.QueryOne("SELECT user_id AS id, stock FROM smoking_wallets WHERE user_id = ?", uid)
if err != nil {
return nil, err
}
if account == nil {
return result, nil
}
aid := account.Int("id")
result["stock"] = account.Int("stock")
summary, err := c.DB.QueryOne("SELECT COUNT(*) AS total_count, COALESCE(SUM(created_at >= CURRENT_DATE),0) AS today_count, MAX(created_at) AS last_time FROM smoking_entries WHERE user_id = ? AND kind IN ('consume','receive_smoke') AND undone = 0", aid)
if err != nil {
return nil, err
}
if summary != nil {
result["today_count"] = summary.Int("today_count")
result["total_count"] = summary.Int("total_count")
result["last_time"] = summary.Str("last_time")
}
logs, err := c.DB.Query("SELECT id, kind, quantity, delta, balance, amount_cents, note, undone, created_at FROM smoking_entries WHERE user_id = ? ORDER BY id DESC LIMIT 50", aid)
if err != nil {
return nil, err
}
result["logs"] = rows(logs)
daily, err := c.DB.Query("SELECT DATE_FORMAT(created_at,'%Y-%m-%d') AS day, COUNT(*) AS count FROM smoking_entries WHERE user_id = ? AND kind IN ('consume','receive_smoke') AND undone = 0 AND created_at >= DATE_SUB(CURRENT_DATE, INTERVAL 6 DAY) GROUP BY DATE_FORMAT(created_at,'%Y-%m-%d') ORDER BY day", aid)
if err != nil {
return nil, err
}
result["daily"] = rows(daily)
return result, nil
}
func smokingInfo(c *httpapi.Ctx) error {
uid := c.UID()
requested := c.QInt("user_id")
if requested == 0 {
requested = uid
}
if requested != uid && requested != partnerUserId(c.DB, uid) {
return c.Err(403, "只能查看自己和当前伴侣的记录")
}
info, err := smokingSnapshot(c, requested)
if err != nil {
return err
}
return c.OK(info)
}
func smokingAction(c *httpapi.Ctx) error {
if err := c.RequirePOST(); err != nil {
return err
}
var err error
uid := c.UID()
kind, key, note := c.InTrim("kind"), c.InTrim("request_key"), c.InTrim("note")
quantity, quantityErr := strconv.ParseInt(c.InTrim("quantity"), 10, 64)
optionalInt := func(key string) (int64, error) {
value := c.InTrim(key)
if value == "" {
return 0, nil
}
return strconv.ParseInt(value, 10, 64)
}
amount, amountErr := optionalInt("amount_cents")
originalID, originalErr := optionalInt("original_id")
if quantityErr != nil || amountErr != nil || originalErr != nil || (kind != "purchase" && amount != 0) || (kind != "undo" && originalID != 0) || (kind == "undo" && (originalID <= 0 || quantity != 0)) {
return c.Err(400, "数量、金额和记录标识必须是有效整数")
}
if key == "" || len(key) > 64 || utf8.RuneCountInString(note) > 200 || amount < 0 || amount > 100000000 {
return c.Err(400, "参数不正确")
}
if kind != "undo" {
if _, err := smokingDelta(kind, quantity, 1000000); err != nil {
return c.Err(400, err.Error())
}
}
if note != "" {
if err := checkTextOr400(c, note); err != nil {
return err
}
}
payload, _ := json.Marshal([]any{kind, quantity, amount, originalID, note})
hash := fmt.Sprintf("%x", sha256.Sum256(payload))
tx, err := c.DB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err = tx.Exec("INSERT IGNORE INTO smoking_wallets (user_id) VALUES (?)", uid); err != nil {
return err
}
account, err := tx.QueryOne("SELECT user_id AS id,stock FROM smoking_wallets WHERE user_id = ? FOR UPDATE", uid)
if err != nil {
return err
}
if account == nil {
return errors.New("库存账户创建失败")
}
aid, stock := account.Int("id"), account.Int("stock")
previous, err := tx.QueryOne("SELECT payload_hash FROM smoking_entries WHERE user_id = ? AND request_key = ?", aid, key)
if err != nil {
return err
}
if previous != nil {
if previous.Str("payload_hash") != hash {
return c.Err(409, "请求标识已被其他操作使用")
}
if err = tx.Commit(); err != nil {
return err
}
info, err := smokingSnapshot(c, uid)
if err != nil {
return err
}
return c.OK(M{"success": true, "info": info})
}
var delta int64
if kind == "undo" {
original, err := tx.QueryOne("SELECT id,delta,kind,undone FROM smoking_entries WHERE user_id = ? AND id = ? FOR UPDATE", aid, originalID)
if err != nil {
return err
}
if original == nil || original.Int("undone") != 0 || original.Str("kind") == "undo" {
return c.Err(409, "这条记录无法撤销或已经撤销")
}
delta = -original.Int("delta")
quantity = 0
if stock+delta < 0 {
return c.Err(409, "库存已使用,无法撤销这次补充,请通过调整库存修正")
}
if _, err = tx.Exec("UPDATE smoking_entries SET undone = 1 WHERE id = ?", originalID); err != nil {
return err
}
} else {
delta, err = smokingDelta(kind, quantity, stock)
if err != nil {
return c.Err(400, err.Error())
}
}
if stock+delta > 1000000 && delta > 0 {
return c.Err(400, "库存不能超过100万支")
}
if _, err = tx.Exec("UPDATE smoking_wallets SET stock = ? WHERE user_id = ?", stock+delta, aid); err != nil {
return err
}
if _, err = tx.Insert("INSERT INTO smoking_entries (user_id,kind,quantity,delta,balance,amount_cents,note,request_key,payload_hash,original_id) VALUES (?,?,?,?,?,?,?,?,?,?)", aid, kind, quantity, delta, stock+delta, amount, strings.TrimSpace(note), key, hash, nullableID(originalID)); err != nil {
return err
}
if err = tx.Commit(); err != nil {
return err
}
info, err := smokingSnapshot(c, uid)
if err != nil {
return err
}
return c.OK(M{"success": true, "info": info})
}
func nullableID(id int64) any {
if id == 0 {
return nil
}
return id
}
+38
View File
@@ -0,0 +1,38 @@
package handler
import "testing"
func TestSmokingDelta(t *testing.T) {
for _, tt := range []struct {
name, kind string
quantity, stock, want int64
valid bool
}{
{"give one", "give", 1, 20, -1, true},
{"cannot give from empty stock", "give", 1, 0, 0, false},
{"give exactly one", "give", 2, 20, 0, false},
{"consume one", "consume", 1, 20, -1, true},
{"empty stock", "consume", 1, 0, 0, false},
{"no bulk smoking", "consume", 2, 20, 0, false},
{"purchase pack", "purchase", 20, 0, 20, true},
{"received one", "receive", 1, 3, 1, true},
{"received and immediately consumed", "receive_smoke", 1, 0, 0, true},
{"initial stock", "adjust", 17, 0, 17, true},
{"inventory correction", "adjust", 3, 17, -14, true},
{"clear inventory", "adjust", 0, 17, -17, true},
{"negative quantity", "receive", -1, 0, 0, false},
{"zero purchase", "purchase", 0, 0, 0, false},
{"too much purchase", "purchase", 10001, 0, 0, false},
{"unknown operation", "other", 1, 0, 0, false},
} {
t.Run(tt.name, func(t *testing.T) {
delta, err := smokingDelta(tt.kind, tt.quantity, tt.stock)
if (err == nil) != tt.valid || delta != tt.want {
t.Fatalf("delta=%d err=%v", delta, err)
}
if err == nil && tt.stock+delta < 0 {
t.Fatal("negative inventory")
}
})
}
}
+4
View File
@@ -321,6 +321,10 @@ func wechatLogin(c *httpapi.Ctx) error {
} }
func checkAuth(c *httpapi.Ctx) error { func checkAuth(c *httpapi.Ctx) error {
if err := c.DB.AdvanceUnbindings(); err != nil {
return err
}
c.CoupleID()
uid := c.UID() uid := c.UID()
if uid == 0 { if uid == 0 {
return c.OK(M{"logged_in": false}) return c.OK(M{"logged_in": false})
+11 -1
View File
@@ -45,6 +45,10 @@ func (c *Ctx) UID() int64 {
// CoupleID 对应 getCoupleId();无则返回 0 // CoupleID 对应 getCoupleId();无则返回 0
func (c *Ctx) CoupleID() int64 { func (c *Ctx) CoupleID() int64 {
if c.coupleResolved {
return c.coupleID
}
c.coupleResolved = true
uid := c.UID() uid := c.UID()
if uid == 0 { if uid == 0 {
return 0 return 0
@@ -53,7 +57,8 @@ func (c *Ctx) CoupleID() int64 {
if row == nil { if row == nil {
return 0 return 0
} }
return row.Int("couple_id") c.coupleID = row.Int("couple_id")
return c.coupleID
} }
// NewToken 生成 32 位 hex token(与 PHP session_id 格式一致) // NewToken 生成 32 位 hex token(与 PHP session_id 格式一致)
@@ -77,6 +82,9 @@ func (c *Ctx) requireUserLogin() error {
if uid == 0 { if uid == 0 {
return c.Err(401, "请先登录") return c.Err(401, "请先登录")
} }
if err := c.DB.AdvanceUnbindings(); err != nil {
return err
}
user, err := c.DB.QueryOne("SELECT couple_id, last_login_at FROM users WHERE id = ?", uid) user, err := c.DB.QueryOne("SELECT couple_id, last_login_at FROM users WHERE id = ?", uid)
if err != nil { if err != nil {
return err return err
@@ -180,3 +188,5 @@ func (c *Ctx) requireAdminLogin() error {
} }
return nil return nil
} }
func (c *Ctx) SetCoupleID(cid int64) { c.coupleID = cid; c.coupleResolved = true }
+19 -9
View File
@@ -5,6 +5,7 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"io" "io"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
@@ -38,13 +39,15 @@ type Ctx struct {
CamelCase bool CamelCase bool
Method string Method string
input map[string]any input map[string]any
inputParsed bool inputParsed bool
body []byte body []byte
bodyRead bool bodyRead bool
uid int64 uid int64
uidResolved bool uidResolved bool
responded bool coupleID int64
coupleResolved bool
responded bool
} }
// errResponded 表示响应已写出(对应 PHP 的 exit)。 // errResponded 表示响应已写出(对应 PHP 的 exit)。
@@ -62,6 +65,10 @@ func (c *Ctx) JSON(code int, v any) error {
return errResponded return errResponded
} }
c.responded = true c.responded = true
if c.uidResolved && c.uid != 0 && c.coupleResolved {
c.G.Header("X-Couple-ID", fmt.Sprint(c.coupleID))
c.G.Header("X-User-ID", fmt.Sprint(c.uid))
}
var buf bytes.Buffer var buf bytes.Buffer
enc := json.NewEncoder(&buf) enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false) enc.SetEscapeHTML(false)
@@ -76,8 +83,11 @@ func (c *Ctx) JSON(code int, v any) error {
if err := dec.Decode(&payload); err != nil { if err := dec.Decode(&payload); err != nil {
return err return err
} }
if c.CamelCase {
payload = renameKeys(payload, camelName)
}
buf.Reset() buf.Reset()
if err := enc.Encode(renameKeys(payload, camelName)); err != nil { if err := enc.Encode(payload); err != nil {
return err return err
} }
} }
@@ -180,7 +190,7 @@ func normalizeNumbers(v any) any {
} }
// In 对应 $input['key'] ?? nil // In 对应 $input['key'] ?? nil
func (c *Ctx) In(key string) any { return c.Input()[key] } func (c *Ctx) In(key string) any { return normalizeMediaInput(c.Input()[key]) }
// InHas 对应 isset($input['key']) // InHas 对应 isset($input['key'])
func (c *Ctx) InHas(key string) bool { func (c *Ctx) InHas(key string) bool {
+47
View File
@@ -0,0 +1,47 @@
package httpapi
import (
"encoding/json"
"net/url"
"strings"
)
func normalizeMediaPath(value string) string {
parsed, err := url.Parse(value)
if err != nil {
return value
}
if strings.HasPrefix(parsed.Path, "/backend/uploads/") {
return strings.TrimPrefix(parsed.Path, "/backend/")
}
if !parsed.IsAbs() && parsed.Host == "" && strings.HasPrefix(parsed.Path, "uploads/") {
return parsed.Path
}
return value
}
func normalizeMediaInput(value any) any {
switch v := value.(type) {
case map[string]any:
for k, item := range v {
v[k] = normalizeMediaInput(item)
}
return v
case []any:
for i, item := range v {
v[i] = normalizeMediaInput(item)
}
return v
case string:
if strings.HasPrefix(v, "[") {
var list []any
if json.Unmarshal([]byte(v), &list) == nil {
data, _ := json.Marshal(normalizeMediaInput(list))
return string(data)
}
}
return normalizeMediaPath(v)
}
return value
}
+48
View File
@@ -0,0 +1,48 @@
package httpapi
import (
"github.com/gin-gonic/gin"
"net/http/httptest"
"net/url"
"os"
"past/internal/config"
"path/filepath"
"strings"
"testing"
)
func TestLegacyPhotoResponsesKeepTheirOriginalPaths(t *testing.T) {
out := httptest.NewRecorder()
g, _ := gin.CreateTestContext(out)
g.Request = httptest.NewRequest("GET", "/api/photos", nil)
c := &Ctx{G: g, R: g.Request, CamelCase: true}
c.OK(M{"path": "uploads/users/1/photos/a.jpg"})
if !strings.Contains(out.Body.String(), `"path":"uploads/users/1/photos/a.jpg"`) {
t.Fatal(out.Body.String())
}
}
func TestPublishedUnsignedPhotoURLsStillLoad(t *testing.T) {
dir := t.TempDir()
folder := filepath.Join(dir, "uploads", "users", "1")
if err := os.MkdirAll(folder, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(folder, "a.jpg"), []byte("photo"), 0600); err != nil {
t.Fatal(err)
}
router := NewRouter(&App{Cfg: &config.Config{DataDir: dir}}, nil).Engine(false)
out := httptest.NewRecorder()
router.ServeHTTP(out, httptest.NewRequest("GET", "/backend/uploads/users/1/a.jpg", nil))
if out.Code != 200 || out.Body.String() != "photo" {
t.Fatalf("code=%d body=%s", out.Code, out.Body.String())
}
}
func TestSignedMediaReturnsToCanonicalStoragePath(t *testing.T) {
resource := "uploads/users/1/diaries/a.jpg"
signed := "https://past.sundynix.cn/backend/" + resource + "?" + url.Values{"media_sig": {"test"}, "media_uid": {"1"}}.Encode()
input := map[string]any{"images": []any{signed}, "encoded": "[\"" + signed + "\"]", "content": "plain text"}
normalized := normalizeMediaInput(input).(map[string]any)
if normalized["images"].([]any)[0] != resource || normalized["encoded"] != "[\""+resource+"\"]" || normalized["content"] != "plain text" {
t.Fatalf("wrong normalization: %v", normalized)
}
}
+1
View File
@@ -18,6 +18,7 @@ type DB struct {
// NumericAsString 为 true 时,整数/浮点列按字符串返回,对齐 PHP < 8.1 的 PDO(模拟预处理)行为: // NumericAsString 为 true 时,整数/浮点列按字符串返回,对齐 PHP < 8.1 的 PDO(模拟预处理)行为:
// 线上为 PHP 8.0,小程序一直接收的是 "id":"12" 这种格式。为 false 时对齐 PHP >= 8.1(原生类型)。 // 线上为 PHP 8.0,小程序一直接收的是 "id":"12" 这种格式。为 false 时对齐 PHP >= 8.1(原生类型)。
NumericAsString bool NumericAsString bool
UploadDir string // 用于解绑完成后的持久化附件清理队列
} }
// Row 一行查询结果,键为列名(或别名)。 // Row 一行查询结果,键为列名(或别名)。
+139
View File
@@ -0,0 +1,139 @@
package store
import (
"crypto/sha256"
"fmt"
"log/slog"
"strings"
"time"
)
// AdvanceUnbindings runs on startup, in the worker, and before authenticated access.
// Expiry is decided with database time under the same row locks as cancellation.
func (db *DB) AdvanceUnbindings() error {
pending, err := db.Query("SELECT DISTINCT couple_id FROM unbind_requests WHERE status = 'cooling' AND cooling_until <= NOW()")
if err != nil {
return err
}
for _, row := range pending {
if err := db.finishUnbinding(row.Int("couple_id")); err != nil {
return err
}
}
return nil
}
func (db *DB) finishUnbinding(cid int64) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
cp, err := tx.QueryOne("SELECT id, couple_space_id FROM couples WHERE id = ? FOR UPDATE", cid)
if err != nil || cp == nil {
return err
}
req, err := tx.QueryOne("SELECT id FROM unbind_requests WHERE couple_id = ? AND status = 'cooling' AND cooling_until <= NOW() FOR UPDATE", cid)
if err != nil || req == nil {
return err
}
if err := queueUnboundFiles(tx, cid, cp.Str("couple_space_id")); err != nil {
return err
}
for _, stmt := range sharedDataDeleteStatements {
args := []any{cid}
if strings.Contains(stmt, "bgver_") {
args = append(args, cid)
}
if _, err = tx.Exec(stmt, args...); err != nil {
return err
}
}
for _, stmt := range []string{
"UPDATE users SET couple_id = NULL, updated_at = NOW() WHERE couple_id = ?",
"UPDATE couples SET status = 'unbound', data_deleted = 1 WHERE id = ?",
"UPDATE unbind_requests SET status = 'completed', completed_at = NOW() WHERE couple_id = ? AND status = 'cooling'",
} {
if _, err = tx.Exec(stmt, cid); err != nil {
return err
}
}
return tx.Commit()
}
func (db *DB) RunUnbindingWorker() {
run := func() {
if err := db.AdvanceUnbindings(); err != nil {
slog.Error("解绑到期处理失败", "err", err)
}
if err := db.cleanUnboundFiles(); err != nil {
slog.Error("解绑附件清理失败,将重试", "err", err)
}
}
run()
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
run()
}
}
// MigrateSmokingRecords copies only legacy smoking inventory; shared journal tables stay unchanged.
func (db *DB) MigrateSmokingRecords() error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err = tx.Exec("INSERT IGNORE INTO settings (`key`,`value`) VALUES ('smoking_records_v1','pending')"); err != nil {
return err
}
marker, err := tx.QueryOne("SELECT `value` FROM settings WHERE `key`='smoking_records_v1' FOR UPDATE")
if err != nil {
return err
}
if marker.Str("value") == "done" {
return tx.Commit()
}
previous, err := tx.QueryOne("SELECT `value` FROM settings WHERE `key`='account_records_v1'")
if err != nil {
return err
}
if previous != nil && previous.Str("value") == "done" {
if _, err = tx.Exec("UPDATE settings SET `value`='done' WHERE `key`='smoking_records_v1'"); err != nil {
return err
}
return tx.Commit()
}
accounts, err := tx.Query("SELECT user_id,SUM(stock) AS stock FROM smoking_accounts GROUP BY user_id")
if err != nil {
return err
}
for _, row := range accounts {
if _, err = tx.Exec("INSERT INTO smoking_wallets (user_id,stock) VALUES (?,?)", row["user_id"], row["stock"]); err != nil {
return err
}
}
logs, err := tx.Query("SELECT l.*,a.user_id FROM smoking_logs l JOIN smoking_accounts a ON a.id=l.account_id ORDER BY l.id ASC")
if err != nil {
return err
}
latestKeys := map[string]int64{}
for _, row := range logs {
latestKeys[fmt.Sprint(row.Int("user_id"), ":", row.Str("request_key"))] = row.Int("id")
}
balances := map[int64]int64{}
for _, row := range logs {
uid := row.Int("user_id")
balances[uid] += row.Int("delta")
key := row.Str("request_key")
if latestKeys[fmt.Sprint(uid, ":", key)] != row.Int("id") {
key = fmt.Sprintf("legacy-%x", sha256.Sum256([]byte(fmt.Sprint(row["id"], ":", key))))[:64]
}
if _, err = tx.Exec("INSERT INTO smoking_entries (id,user_id,kind,quantity,delta,balance,amount_cents,note,request_key,payload_hash,original_id,undone,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", row["id"], uid, row["kind"], row["quantity"], row["delta"], balances[uid], row["amount_cents"], row["note"], key, row["payload_hash"], row["original_id"], row["undone"], row["created_at"]); err != nil {
return err
}
}
if _, err = tx.Exec("UPDATE settings SET `value`='done' WHERE `key`='smoking_records_v1'"); err != nil {
return err
}
return tx.Commit()
}
@@ -0,0 +1,137 @@
package store
import (
"database/sql/driver"
"errors"
"fmt"
"past/internal/testdb"
"strings"
"testing"
)
func TestCoolingCompletionRechecksUnderLock(t *testing.T) {
for _, cancelled := range []bool{false, true} {
t.Run(map[bool]string{true: "cancelled before lock", false: "deadline reached"}[cancelled], func(t *testing.T) {
reqRows := [][]driver.Value{{int64(8)}}
if cancelled {
reqRows = nil
}
steps := []testdb.Step{
{Match: "SELECT DISTINCT couple_id", Columns: []string{"couple_id"}, Rows: [][]driver.Value{{int64(7)}}},
{Match: "FROM couples WHERE id = ? FOR UPDATE", Args: []any{int64(7)}, Columns: []string{"id"}, Rows: [][]driver.Value{{int64(7)}}},
{Match: "cooling_until <= NOW() FOR UPDATE", Columns: []string{"id"}, Rows: reqRows},
}
if !cancelled {
steps = append(steps, cleanupQuerySteps()...)
for _, stmt := range sharedDataDeleteStatements {
args := []any{int64(7)}
if strings.Contains(stmt, "bgver_") {
args = append(args, int64(7))
}
steps = append(steps, testdb.Step{Match: stmt, Args: args})
}
steps = append(steps, testdb.Step{Match: "UPDATE users SET couple_id = NULL"}, testdb.Step{Match: "data_deleted = 1"}, testdb.Step{Match: "status = 'completed'"})
}
sqlDB, s := testdb.Open(t, steps)
db := &DB{DB: sqlDB}
if err := db.AdvanceUnbindings(); err != nil {
t.Fatal(err)
}
if cancelled && s.Commits != 0 {
t.Fatal("cancelled relationship committed unbinding")
}
if !cancelled && s.Commits != 1 {
t.Fatal("completed unbinding did not commit")
}
})
}
}
func TestCoolingFailureRollsBackRelationship(t *testing.T) {
steps := []testdb.Step{
{Match: "SELECT DISTINCT", Columns: []string{"couple_id"}, Rows: [][]driver.Value{{int64(7)}}},
{Match: "FOR UPDATE", Columns: []string{"id"}, Rows: [][]driver.Value{{int64(7)}}},
{Match: "FOR UPDATE", Columns: []string{"id"}, Rows: [][]driver.Value{{int64(8)}}},
}
steps = append(steps, cleanupQuerySteps()...)
steps = append(steps, testdb.Step{Match: sharedDataDeleteStatements[0], Err: errors.New("database unavailable")})
sqlDB, s := testdb.Open(t, steps)
db := &DB{DB: sqlDB}
if err := db.AdvanceUnbindings(); err == nil {
t.Fatal("failure ignored")
}
if s.Commits != 0 || s.Rollbacks != 1 {
t.Fatalf("commits=%d rollbacks=%d", s.Commits, s.Rollbacks)
}
}
func TestSmokingMigrationDoesNotRepeat(t *testing.T) {
sqlDB, s := testdb.Open(t, []testdb.Step{{Match: "INSERT IGNORE INTO settings"}, {Match: "FOR UPDATE", Columns: []string{"value"}, Rows: [][]driver.Value{{"done"}}}})
if err := (&DB{DB: sqlDB}).MigrateSmokingRecords(); err != nil {
t.Fatal(err)
}
if s.Commits != 1 {
t.Fatal("migration marker not committed")
}
}
func TestSmokingMigrationPreservesMergedInventory(t *testing.T) {
checkEntry := func(id string, balance int64, key string) func(string, []any) error {
return func(_ string, a []any) error {
if a[0] != id || a[1] != int64(1) || a[5] != balance {
return fmt.Errorf("lost ledger id/owner/balance: %v", a)
}
if key != "" && a[8] != key {
return fmt.Errorf("latest retry key changed")
}
if key == "" && (a[8] == "same-key" || len(a[8].(string)) > 64) {
return fmt.Errorf("key collision not resolved")
}
return nil
}
}
columns := []string{"id", "user_id", "kind", "quantity", "delta", "amount_cents", "note", "request_key", "payload_hash", "original_id", "undone", "created_at"}
sqlDB, s := testdb.Open(t, []testdb.Step{
{Match: "INSERT IGNORE INTO settings"}, {Match: "FOR UPDATE", Columns: []string{"value"}, Rows: [][]driver.Value{{"pending"}}},
{Match: "WHERE `key`='account_records_v1'", Columns: []string{"value"}},
{Match: "SUM(stock)", Columns: []string{"user_id", "stock"}, Rows: [][]driver.Value{{int64(1), int64(19)}}},
{Match: "INSERT INTO smoking_wallets", Args: []any{"1", "19"}},
{Match: "FROM smoking_logs", Columns: columns, Rows: [][]driver.Value{
{int64(10), int64(1), "purchase", int64(10), int64(10), int64(0), "", "same-key", "hash", nil, int64(0), "2026-10-01 00:00:00"},
{int64(11), int64(1), "purchase", int64(10), int64(10), int64(0), "", "same-key", "hash", nil, int64(0), "2026-10-02 00:00:00"},
{int64(12), int64(1), "consume", int64(1), int64(-1), int64(0), "", "consume-key", "hash", nil, int64(0), "2026-10-03 00:00:00"},
}},
{Match: "INSERT INTO smoking_entries", Check: checkEntry("10", 10, "")}, {Match: "INSERT INTO smoking_entries", Check: checkEntry("11", 20, "same-key")}, {Match: "INSERT INTO smoking_entries", Check: checkEntry("12", 19, "consume-key")},
{Match: "SET `value`='done'"},
})
if err := (&DB{DB: sqlDB}).MigrateSmokingRecords(); err != nil {
t.Fatal(err)
}
if s.Commits != 1 {
t.Fatal("migration was not atomic")
}
}
func TestSmokingMigrationRecognizesPreviousAccountMigration(t *testing.T) {
db, script := testdb.Open(t, []testdb.Step{
{Match: "INSERT IGNORE INTO settings"},
{Match: "smoking_records_v1' FOR UPDATE", Columns: []string{"value"}, Rows: [][]driver.Value{{"pending"}}},
{Match: "WHERE `key`='account_records_v1'", Columns: []string{"value"}, Rows: [][]driver.Value{{"done"}}},
{Match: "UPDATE settings SET `value`='done' WHERE `key`='smoking_records_v1'"},
})
if err := (&DB{DB: db}).MigrateSmokingRecords(); err != nil {
t.Fatal(err)
}
if script.Commits != 1 {
t.Fatal("previous smoking migration was not acknowledged")
}
}
func cleanupQuerySteps() []testdb.Step {
return []testdb.Step{
{Match: "SELECT path AS media", Columns: []string{"media"}},
{Match: "SELECT cover AS media", Columns: []string{"media"}},
{Match: "SELECT images AS media FROM diaries", Columns: []string{"media"}},
{Match: "SELECT images AS media FROM timeline", Columns: []string{"media"}},
{Match: "SELECT bg_image AS media", Columns: []string{"media"}},
{Match: "SELECT `value` AS media", Columns: []string{"media"}},
}
}
+20
View File
@@ -140,6 +140,26 @@ func (db *DB) InitSchema() error {
} }
} }
if _, err := d.Exec("UPDATE unbind_requests r JOIN couples c ON c.id=r.couple_id SET r.status=CASE WHEN c.status='unbound' THEN 'completed' ELSE 'cooling' END,r.cooling_until=CASE WHEN c.status='unbound' THEN NULL ELSE DATE_ADD(NOW(),INTERVAL 72 HOUR) END WHERE r.status='agreed'"); err != nil {
return err
}
idx, err := d.Query("SHOW INDEX FROM unbind_requests WHERE Key_name='unbind_deadline'")
if err != nil {
return err
}
if len(idx) == 0 {
if _, err := d.Exec("CREATE INDEX unbind_deadline ON unbind_requests(status,cooling_until)"); err != nil {
return err
}
}
for _, stmt := range []string{
"CREATE TABLE IF NOT EXISTS smoking_wallets (user_id INT PRIMARY KEY, stock INT NOT NULL DEFAULT 0, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
"CREATE TABLE IF NOT EXISTS smoking_entries (id INT AUTO_INCREMENT PRIMARY KEY,user_id INT NOT NULL,kind VARCHAR(20) NOT NULL,quantity INT NOT NULL,delta INT NOT NULL,balance INT NOT NULL,amount_cents INT NOT NULL DEFAULT 0,note VARCHAR(200) NOT NULL DEFAULT '',request_key VARCHAR(64) NOT NULL,payload_hash CHAR(64) NOT NULL,original_id INT DEFAULT NULL,undone TINYINT NOT NULL DEFAULT 0,created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,UNIQUE KEY smoking_personal_request(user_id,request_key),KEY smoking_personal_recent(user_id,created_at),FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
} {
if _, err := d.Exec(stmt); err != nil {
return err
}
}
return nil return nil
} }
+41
View File
@@ -5,6 +5,12 @@ package store
// createTables 对应 install.php 中全部 CREATE TABLE IF NOT EXISTS(按原顺序)。 // createTables 对应 install.php 中全部 CREATE TABLE IF NOT EXISTS(按原顺序)。
var createTables = []string{ var createTables = []string{
`CREATE TABLE IF NOT EXISTS unbinding_file_cleanup (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
path VARCHAR(500) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS users ( `CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
user_id VARCHAR(20) UNIQUE, user_id VARCHAR(20) UNIQUE,
@@ -172,6 +178,37 @@ var createTables = []string{
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (checkin_id) REFERENCES checkins(id) ON DELETE CASCADE FOREIGN KEY (checkin_id) REFERENCES checkins(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS smoking_accounts (
id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NOT NULL,
user_id INT NOT NULL,
stock INT NOT NULL DEFAULT 0,
checkin_id INT DEFAULT NULL,
UNIQUE KEY smoking_owner (couple_id, user_id),
FOREIGN KEY (couple_id) REFERENCES couples(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (checkin_id) REFERENCES checkins(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS smoking_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
account_id INT NOT NULL,
kind VARCHAR(20) NOT NULL,
quantity INT NOT NULL,
delta INT NOT NULL,
balance INT NOT NULL,
amount_cents INT NOT NULL DEFAULT 0,
note VARCHAR(200) NOT NULL DEFAULT '',
request_key VARCHAR(64) NOT NULL,
payload_hash CHAR(64) NOT NULL,
checkin_log_id INT DEFAULT NULL,
original_id INT DEFAULT NULL,
undone TINYINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY smoking_request (account_id, request_key),
KEY smoking_recent (account_id, created_at),
FOREIGN KEY (account_id) REFERENCES smoking_accounts(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS period_records ( `CREATE TABLE IF NOT EXISTS period_records (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
couple_id INT NULL, couple_id INT NULL,
@@ -366,6 +403,10 @@ var createTables = []string{
// addColumns 对应 install.php 中的 ALTER TABLE ADD COLUMN 迁移(原代码用 try/catch 或 SHOW COLUMNS 判断,这里统一按列是否存在判断)。 // addColumns 对应 install.php 中的 ALTER TABLE ADD COLUMN 迁移(原代码用 try/catch 或 SHOW COLUMNS 判断,这里统一按列是否存在判断)。
var addColumns = []addColumn{ var addColumns = []addColumn{
{"unbind_requests", "cancelled_at", "DATETIME DEFAULT NULL"},
{"unbind_requests", "cancelled_by", "INT DEFAULT NULL"},
{"unbind_requests", "cooling_until", "DATETIME DEFAULT NULL"},
{"unbind_requests", "completed_at", "DATETIME DEFAULT NULL"},
{"checkins", "type", "VARCHAR(10) DEFAULT 'dual'"}, {"checkins", "type", "VARCHAR(10) DEFAULT 'dual'"},
{"checkins", "frequency", "VARCHAR(10) DEFAULT 'daily'"}, {"checkins", "frequency", "VARCHAR(10) DEFAULT 'daily'"},
{"checkins", "completed_by_me", "INT DEFAULT 0"}, {"checkins", "completed_by_me", "INT DEFAULT 0"},
+148
View File
@@ -0,0 +1,148 @@
package store
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
// 仅删除当前情侣空间的数据,不按用户 ID 删除其他空间或个人账本。
var sharedDataDeleteStatements = []string{
"DELETE l FROM smoking_logs l JOIN smoking_accounts a ON a.id=l.account_id WHERE a.couple_id = ?",
"DELETE FROM smoking_accounts WHERE couple_id = ?",
"DELETE FROM diary_comments WHERE couple_id = ?",
"DELETE FROM checkin_logs WHERE couple_id = ?",
"DELETE p FROM photos p JOIN albums a ON a.id=p.album_id WHERE a.couple_id = ?",
"DELETE FROM albums WHERE couple_id = ?",
"DELETE FROM diaries WHERE couple_id = ?",
"DELETE FROM timeline WHERE couple_id = ?",
"DELETE FROM songs WHERE couple_id = ?",
"DELETE FROM checkins WHERE couple_id = ?",
"DELETE FROM period_records WHERE couple_id = ?",
"DELETE FROM period_daily_records WHERE couple_id = ?",
"DELETE FROM period_sex_records WHERE couple_id = ?",
"DELETE FROM pet_dailies WHERE couple_id = ?",
"DELETE FROM pet_achievements WHERE couple_id = ?",
"DELETE FROM pet_items WHERE couple_id = ?",
"DELETE FROM pet_dynamics WHERE couple_id = ?",
"DELETE FROM pets WHERE couple_id = ?",
"DELETE FROM activity_log WHERE couple_id = ?",
"DELETE FROM notify_log WHERE couple_id = ?",
"DELETE FROM settings WHERE `key` IN (CONCAT('bg_', ?), CONCAT('bgver_', ?))",
}
func queueUnboundFiles(tx *Tx, cid int64, space string) error {
queries := []string{
"SELECT path AS media FROM photos WHERE album_id IN (SELECT id FROM albums WHERE couple_id = ?)",
"SELECT cover AS media FROM albums WHERE couple_id = ?",
"SELECT images AS media FROM diaries WHERE couple_id = ?",
"SELECT images AS media FROM timeline WHERE couple_id = ?",
"SELECT bg_image AS media FROM timeline WHERE couple_id = ?",
"SELECT `value` AS media FROM settings WHERE `key` = CONCAT('bg_', ?)",
}
paths := map[string]bool{}
for _, query := range queries {
records, err := tx.Query(query, cid)
if err != nil {
return err
}
for _, record := range records {
value := record.Str("media")
var images []string
if json.Unmarshal([]byte(value), &images) == nil {
for _, image := range images {
if p := cleanupPath(image); p != "" {
paths[p] = true
}
}
} else if p := cleanupPath(value); p != "" {
paths[p] = true
}
}
}
// 清除该空间目录内已失去引用的附件,保留用户头像和个人目录。
if space == fmt.Sprintf("CP%06d", cid) {
paths["couples/"+space+"/"] = true
}
for path := range paths {
if _, err := tx.Exec("INSERT INTO unbinding_file_cleanup (couple_id,path) VALUES (?,?)", cid, path); err != nil {
return err
}
}
return nil
}
func cleanupPath(value string) string {
value = strings.TrimSpace(value)
value = strings.TrimPrefix(value, "/backend/")
value = strings.TrimPrefix(value, "backend/")
value = strings.TrimPrefix(value, "/")
if !strings.HasPrefix(value, "uploads/") {
return ""
}
value = strings.SplitN(value, "?", 2)[0]
value = strings.TrimPrefix(value, "uploads/")
if strings.ContainsAny(value, "\\:\x00") {
return ""
}
for _, segment := range strings.Split(value, "/") {
if segment == "." || segment == ".." || segment == "" {
return ""
}
}
if strings.HasPrefix(value, "avatars/") {
return ""
}
return value
}
// 文件删除在数据库提交之后执行;失败保留任务,下次启动或轮询重试。
func (db *DB) cleanUnboundFiles() error {
if db.UploadDir == "" {
return nil
}
tasks, err := db.Query("SELECT id,couple_id,path FROM unbinding_file_cleanup ORDER BY id LIMIT 500")
if err != nil {
return err
}
for _, task := range tasks {
path := task.Str("path")
directory := path == "couples/"+fmt.Sprintf("CP%06d", task.Int("couple_id"))+"/"
if !directory && cleanupPath("uploads/"+path) != path {
return fmt.Errorf("invalid unbinding cleanup path %q", path)
}
target := filepath.Join(db.UploadDir, filepath.FromSlash(path))
// 不跟随目录链接,避免删除上传根目录之外的数据。
parent := filepath.Dir(target)
if directory {
parent = filepath.Dir(strings.TrimSuffix(target, string(filepath.Separator)))
}
root, rootErr := filepath.EvalSymlinks(db.UploadDir)
actual, parentErr := filepath.EvalSymlinks(parent)
if rootErr != nil {
return rootErr
}
if parentErr == nil {
rel, err := filepath.Rel(root, actual)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return fmt.Errorf("cleanup outside upload directory")
}
} else if !os.IsNotExist(parentErr) {
return parentErr
}
if directory {
err = os.RemoveAll(target)
} else {
err = os.Remove(target)
}
if err != nil && !os.IsNotExist(err) {
return err
}
if _, err = db.Exec("DELETE FROM unbinding_file_cleanup WHERE id = ?", task.Int("id")); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,61 @@
package store
import (
"database/sql/driver"
"os"
"past/internal/testdb"
"path/filepath"
"testing"
)
func TestCleanupPathsProtectPersonalAndOutsideFiles(t *testing.T) {
for _, input := range []string{"uploads/../secret", "uploads/users/1/../../secret", "uploads/avatars/520001/avatar.jpg", "https://example.com/image.jpg", "uploads/C:\\secret", "uploads//secret"} {
if cleanupPath(input) != "" {
t.Fatalf("unsafe or personal path accepted: %q", input)
}
}
if got := cleanupPath("/backend/uploads/users/1/diary/a.jpg?v=1"); got != "users/1/diary/a.jpg" {
t.Fatal(got)
}
}
func TestUnbindingFilesDeleteOnlyQueuedMediaAndRetryMissingFiles(t *testing.T) {
root := t.TempDir()
shared := filepath.Join(root, "users/1/diary/a.jpg")
avatar := filepath.Join(root, "avatars/520001/avatar.jpg")
for _, p := range []string{shared, avatar} {
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte("image"), 0600); err != nil {
t.Fatal(err)
}
}
db, _ := testdb.Open(t, []testdb.Step{
{Match: "FROM unbinding_file_cleanup", Columns: []string{"id", "couple_id", "path"}, Rows: [][]driver.Value{{int64(1), int64(7), "users/1/diary/a.jpg"}, {int64(2), int64(7), "users/1/diary/already-gone.jpg"}}},
{Match: "DELETE FROM unbinding_file_cleanup", Args: []any{int64(1)}},
{Match: "DELETE FROM unbinding_file_cleanup", Args: []any{int64(2)}},
})
if err := (&DB{DB: db, UploadDir: root}).cleanUnboundFiles(); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(shared); !os.IsNotExist(err) {
t.Fatal("shared attachment remains")
}
if _, err := os.Stat(avatar); err != nil {
t.Fatal("personal avatar deleted")
}
}
func TestCleanupFailureRetainsTaskForRetry(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "users/1/diary/folder")
if err := os.MkdirAll(target, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(target, "child"), []byte("x"), 0600); err != nil {
t.Fatal(err)
}
db, _ := testdb.Open(t, []testdb.Step{{Match: "FROM unbinding_file_cleanup", Columns: []string{"id", "couple_id", "path"}, Rows: [][]driver.Value{{int64(1), int64(7), "users/1/diary/folder"}}}})
if err := (&DB{DB: db, UploadDir: root}).cleanUnboundFiles(); err == nil {
t.Fatal("failure ignored, task must remain queued")
}
}
+144
View File
@@ -0,0 +1,144 @@
// Package testdb supplies a deterministic database/sql driver for transaction failure tests.
package testdb
import (
"context"
"database/sql"
"database/sql/driver"
"fmt"
"io"
"reflect"
"strings"
"sync"
"sync/atomic"
"testing"
)
type Step struct {
Check func(string, []any) error
Match string
Args []any
Columns []string
Rows [][]driver.Value
Err error
ID int64
}
type Script struct {
Steps []Step
Index int
Commits, Rollbacks int
mu sync.Mutex
}
var scripts sync.Map
var sequence atomic.Int64
func init() { sql.Register("past_test_script", scriptDriver{}) }
func Open(t *testing.T, steps []Step) (*sql.DB, *Script) {
t.Helper()
name := fmt.Sprint(sequence.Add(1))
s := &Script{Steps: steps}
scripts.Store(name, s)
db, err := sql.Open("past_test_script", name)
if err != nil {
t.Fatal(err)
}
db.SetMaxOpenConns(1)
t.Cleanup(func() {
db.Close()
scripts.Delete(name)
if s.Index != len(s.Steps) {
t.Errorf("executed %d of %d SQL steps", s.Index, len(s.Steps))
}
})
return db, s
}
type scriptDriver struct{}
func (scriptDriver) Open(name string) (driver.Conn, error) {
s, _ := scripts.Load(name)
return &conn{s.(*Script)}, nil
}
type conn struct{ s *Script }
func (c *conn) Prepare(string) (driver.Stmt, error) {
return nil, fmt.Errorf("unexpected prepared statement")
}
func (c *conn) Close() error { return nil }
func (c *conn) Begin() (driver.Tx, error) { return &tx{c.s}, nil }
func (c *conn) BeginTx(context.Context, driver.TxOptions) (driver.Tx, error) { return c.Begin() }
func (c *conn) step(query string, args []driver.NamedValue) (Step, error) {
c.s.mu.Lock()
defer c.s.mu.Unlock()
if c.s.Index >= len(c.s.Steps) {
return Step{}, fmt.Errorf("unexpected SQL %s", query)
}
s := c.s.Steps[c.s.Index]
c.s.Index++
if !strings.Contains(query, s.Match) {
return s, fmt.Errorf("expected SQL containing %q got %s", s.Match, query)
}
if s.Args != nil {
values := make([]any, len(args))
for i, a := range args {
values[i] = a.Value
}
if !reflect.DeepEqual(values, s.Args) {
return s, fmt.Errorf("SQL args %v wanted %v", values, s.Args)
}
}
if s.Check != nil {
values := make([]any, len(args))
for i, a := range args {
values[i] = a.Value
}
if err := s.Check(query, values); err != nil {
return s, err
}
}
return s, s.Err
}
func (c *conn) QueryContext(_ context.Context, q string, a []driver.NamedValue) (driver.Rows, error) {
s, err := c.step(q, a)
if err != nil {
return nil, err
}
return &rows{columns: s.Columns, data: s.Rows}, nil
}
func (c *conn) ExecContext(_ context.Context, q string, a []driver.NamedValue) (driver.Result, error) {
s, err := c.step(q, a)
if err != nil {
return nil, err
}
return result{s.ID}, nil
}
type tx struct{ s *Script }
func (t *tx) Commit() error { t.s.Commits++; return nil }
func (t *tx) Rollback() error { t.s.Rollbacks++; return nil }
type result struct{ id int64 }
func (r result) LastInsertId() (int64, error) { return r.id, nil }
func (r result) RowsAffected() (int64, error) { return 1, nil }
type rows struct {
columns []string
data [][]driver.Value
index int
}
func (r *rows) Columns() []string { return r.columns }
func (r *rows) Close() error { return nil }
func (r *rows) Next(dest []driver.Value) error {
if r.index >= len(r.data) {
return io.EOF
}
copy(dest, r.data[r.index])
r.index++
return nil
}
+12 -51
View File
@@ -1,7 +1,6 @@
import { useState } from "react" import { useState } from "react"
import { useQuery, useQueryClient } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { RefreshCw } from "lucide-react" import { RefreshCw } from "lucide-react"
import { toast } from "sonner"
import { api, fmtTime } from "@/lib/api" import { api, fmtTime } from "@/lib/api"
import { Avatar } from "@/components/ImagePreview" import { Avatar } from "@/components/ImagePreview"
import { Pagination, PAGE_SIZE, type Paged } from "@/components/Pagination" import { Pagination, PAGE_SIZE, type Paged } from "@/components/Pagination"
@@ -12,44 +11,31 @@ import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"
const STATUS: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = { const STATUS: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
pending: { label: "⏳ 待回应", variant: "outline" }, pending: { label: "⏳ 待回应", variant: "outline" },
agreed: { label: "✅ 已同意(已解绑)", variant: "default" }, cooling: { label: "冷静期", variant: "secondary" },
completed: { label: "解绑完成", variant: "default" },
agreed: { label: "历史已同意", variant: "outline" },
rejected: { label: "❌ 已拒绝", variant: "destructive" }, rejected: { label: "❌ 已拒绝", variant: "destructive" },
cancelled: { label: "↩️ 已撤销", variant: "secondary" }, cancelled: { label: "↩️ 已撤销", variant: "secondary" },
expired: { label: "⏰ 已过期", variant: "secondary" }, expired: { label: "⏰ 已过期", variant: "secondary" },
} }
type Action = { kind: "restore" | "delete"; coupleId: number; name: string; step: number }
export default function UnbindPage() { export default function UnbindPage() {
const qc = useQueryClient()
const [filter, setFilter] = useState("all") const [filter, setFilter] = useState("all")
const [action, setAction] = useState<Action | null>(null)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const q = useQuery({ const q = useQuery({
queryKey: ["admin_unbind_requests", filter, page], queryKey: ["admin_unbind_requests", filter, page],
refetchInterval: 30000,
queryFn: () => api<Paged>("adminUnbindRequests", { status: filter === "all" ? undefined : filter, page, page_size: PAGE_SIZE }, "GET"), queryFn: () => api<Paged>("adminUnbindRequests", { status: filter === "all" ? undefined : filter, page, page_size: PAGE_SIZE }, "GET"),
}) })
const rows = q.data?.items || [] const rows = q.data?.items || []
async function run() {
if (!action) return
// 删除数据需要二次确认(与旧版一致)
if (action.kind === "delete" && action.step === 0) return setAction({ ...action, step: 1 })
try {
const r = await api(action.kind === "restore" ? "admin_restore_couple" : "admin_delete_couple_data", { couple_id: action.coupleId })
toast.success(r.msg || "操作成功 ✅")
qc.invalidateQueries({ queryKey: ["admin_unbind_requests"] })
qc.invalidateQueries({ queryKey: ["admin_couples"] })
} catch (e) { toast.error((e as Error).message) } finally { setAction(null) }
}
return ( return (
<div> <div>
<PageTitle count={q.data ? `共 ${q.data.total} 条记录` : undefined}>解绑管理</PageTitle> <PageTitle count={q.data ? `共 ${q.data.total} 条记录` : undefined}>解绑管理</PageTitle>
<p className="mb-4 text-sm text-muted-foreground">对方同意后进入72小时冷静期,期间双方均可撤销;到期系统自动解绑并删除共同数据,无需管理员审批。</p>
<Card> <Card>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="flex flex-wrap items-end gap-3"> <div className="flex flex-wrap items-end gap-3">
@@ -61,7 +47,7 @@ export default function UnbindPage() {
{q.isPending ? <Loading rows={5} /> : q.isError ? <ErrorBox error={q.error} /> : !rows.length ? <Empty icon="🔓" text="暂无解绑记录" /> : ( {q.isPending ? <Loading rows={5} /> : q.isError ? <ErrorBox error={q.error} /> : !rows.length ? <Empty icon="🔓" text="暂无解绑记录" /> : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader><TableRow><TableHead>情侣</TableHead><TableHead>发起方</TableHead><TableHead>状态</TableHead><TableHead>发起时间</TableHead><TableHead>回应时间</TableHead><TableHead>操作</TableHead></TableRow></TableHeader> <TableHeader><TableRow><TableHead>情侣</TableHead><TableHead>发起方</TableHead><TableHead>状态</TableHead><TableHead>发起时间</TableHead><TableHead>回应时间</TableHead><TableHead>冷静期截止</TableHead><TableHead>撤销时间 / 撤销方</TableHead><TableHead>解绑完成时间</TableHead><TableHead>数据状态</TableHead></TableRow></TableHeader>
<TableBody> <TableBody>
{rows.map((r, i) => { {rows.map((r, i) => {
const name = `${r.user1_name || "?"} 💕 ${r.user2_name || "?"}` const name = `${r.user1_name || "?"} 💕 ${r.user2_name || "?"}`
@@ -74,16 +60,10 @@ export default function UnbindPage() {
<TableCell>{restored ? <Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">🔄 已恢复关系</Badge> : <Badge variant={st?.variant || "outline"}>{st?.label || r.status}</Badge>}</TableCell> <TableCell>{restored ? <Badge className="bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">🔄 已恢复关系</Badge> : <Badge variant={st?.variant || "outline"}>{st?.label || r.status}</Badge>}</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(r.created_at)}</TableCell> <TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(r.created_at)}</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(r.responded_at)}</TableCell> <TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(r.responded_at)}</TableCell>
<TableCell> <TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(r.cooling_until)}</TableCell>
{r.status === "agreed" && !restored ? ( <TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(r.cancelled_at)}{r.cancelled_by_name && <div>{r.cancelled_by_name}</div>}</TableCell>
<div className="flex gap-1.5"> <TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(r.completed_at)}</TableCell>
<Button size="sm" className="bg-emerald-500 text-white hover:bg-emerald-600" onClick={() => setAction({ kind: "restore", coupleId: r.couple_id, name, step: 0 })}>🔄 恢复关系</Button> <TableCell className="whitespace-nowrap text-xs">{Number(r.data_deleted) === 1 ? "共同数据已删除" : r.status === "cooling" ? "到期自动删除" : "数据保留"}</TableCell>
<Button size="sm" variant="destructive" onClick={() => setAction({ kind: "delete", coupleId: r.couple_id, name, step: 0 })}>🗑️ 删除数据</Button>
</div>
) : restored ? <span className="text-xs text-emerald-600">✅ 已恢复</span>
: r.status === "pending" ? <span className="text-xs text-amber-600">⏳ 等待双方同意</span>
: <span className="text-xs text-muted-foreground">-</span>}
</TableCell>
</TableRow> </TableRow>
) )
})} })}
@@ -95,26 +75,7 @@ export default function UnbindPage() {
</CardContent> </CardContent>
</Card> </Card>
<AlertDialog open={!!action} onOpenChange={(o) => !o && setAction(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{action?.kind === "restore" ? "🔄 恢复情侣关系" : action?.step === 1 ? "⚠️ 再次确认" : "⚠️ 永久删除情侣数据"}</AlertDialogTitle>
<AlertDialogDescription className="whitespace-pre-line">
{action?.kind === "restore"
? `确定要恢复「${action.name}」的情侣关系吗?\n所有数据将恢复,双方用户可继续使用。`
: action?.step === 1
? "此操作将彻底删除所有情侣数据。确定继续吗?"
: `确定要永久删除「${action?.name}」的所有情侣数据吗?\n\n此操作将:\n1. 删除所有情侣内容(日记、记忆日、相册、照片、歌曲、打卡等)\n2. 保留双方用户账号,可重新邀请绑定\n3. 删除情侣空间文件夹\n\n此操作不可恢复!`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction variant={action?.kind === "delete" ? "destructive" : "default"} onClick={(e) => { e.preventDefault(); run() }}>
{action?.kind === "restore" ? "确认恢复" : action?.step === 1 ? "确认删除" : "继续"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
) )
} }
+23
View File
@@ -23,6 +23,29 @@ App({
}); });
}, },
onRelationChanged: function() {
if (this._relationRedirect) return;
wx.removeStorageSync('petHasPet');
this._relationRedirect = true;
setTimeout(() => { this._relationRedirect=false; wx.reLaunch({url:'/pages/index/index'}); }, 0);
},
onShow: function() {
const sync = async () => {
if (this._relationSync || this.globalData.authState !== 'ready') return;
this._relationSync = true;
const before = String(wx.getStorageSync('coupleId') || '');
try {
await api.checkAuth();
if (before !== String(wx.getStorageSync('coupleId') || '')) {
this.onRelationChanged();
}
} catch(e) {} finally { this._relationSync = false; }
};
sync();
if (!this._relationTimer) this._relationTimer = setInterval(sync, 30000);
},
onHide: function() { if(this._relationTimer) clearInterval(this._relationTimer); this._relationTimer=null; },
setAuthState: function(state, message) { setAuthState: function(state, message) {
this.globalData.authState = state; this.globalData.authState = state;
this.globalData.authMessage = message || ''; this.globalData.authMessage = message || '';
+25 -2
View File
@@ -19,13 +19,16 @@
"pages/diary_detail/diary_detail", "pages/diary_detail/diary_detail",
"pages/diary_edit/diary_edit", "pages/diary_edit/diary_edit",
"pages/pet/pet", "pages/pet/pet",
"pages/pet/achievements" "pages/pet/achievements",
"pages/smoking-detail/smoking-detail"
], ],
"window": { "window": {
"backgroundTextStyle": "light", "backgroundTextStyle": "light",
"navigationStyle": "custom", "navigationStyle": "custom",
"navigationBarTextStyle": "black", "navigationBarTextStyle": "black",
"backgroundColor": "#FAF5EE" "backgroundColor": "#FDE4EC",
"backgroundColorTop": "#FDE4EC",
"backgroundColorBottom": "#FFF7FA"
}, },
"style": "v2", "style": "v2",
"sitemapLocation": "sitemap.json", "sitemapLocation": "sitemap.json",
@@ -43,5 +46,25 @@
], ],
"usingComponents": { "usingComponents": {
"launch-transition": "/components/launch-transition/launch-transition" "launch-transition": "/components/launch-transition/launch-transition"
},
"tabBar": {
"custom": true,
"color": "#8B827B",
"selectedColor": "#B74768",
"backgroundColor": "#FFF0F5",
"list": [
{
"pagePath": "pages/index/index",
"text": "我们"
},
{
"pagePath": "pages/discover/discover",
"text": "记录"
},
{
"pagePath": "pages/profile/profile",
"text": "我的"
}
]
} }
} }
+3 -1
View File
@@ -20,7 +20,7 @@ page {
--text-main: #333333; --text-main: #333333;
--text-sub: #888888; --text-sub: #888888;
--text-light: #BBBBBB; --text-light: #BBBBBB;
--bg: #FAF5EE; --bg: #FFF7FA;
--bg-soft: #F3EBE1; --bg-soft: #F3EBE1;
--white: #FFFFFF; --white: #FFFFFF;
@@ -295,3 +295,5 @@ scroll-view ::-webkit-scrollbar { display: none; }
border: none; border: none;
} }
.empty-btn::after { border: none; } .empty-btn::after { border: none; }
@import './styles/typography.wxss';
+1 -1
View File
@@ -7,6 +7,6 @@ Component({
methods: { goBack: function() { methods: { goBack: function() {
this.triggerEvent('back'); this.triggerEvent('back');
if (getCurrentPages().length > 1) wx.navigateBack(); if (getCurrentPages().length > 1) wx.navigateBack();
else wx.reLaunch({ url: '/pages/index/index' }); else wx.switchTab({ url: '/pages/index/index' });
} } } }
}); });
+5 -1
View File
@@ -1 +1,5 @@
.nav{position:fixed;top:0;left:0;right:0;z-index:900;background:#FAF5EE;box-sizing:border-box}.bar{position:relative;display:flex;align-items:center;justify-content:center;box-sizing:border-box}.title{font-size:32rpx;font-weight:600;color:#352E2A;white-space:nowrap}.back{position:absolute;left:20rpx;top:0;bottom:0;width:80rpx;display:flex;align-items:center;justify-content:center}.back image{width:36rpx;height:36rpx} .nav{position:fixed;top:0;left:0;right:0;z-index:900;background:#FDE4EC;box-sizing:border-box}.bar{position:relative;display:flex;align-items:center;justify-content:center;box-sizing:border-box}.title{font-size:32rpx;font-weight:600;color:#352E2A;white-space:nowrap}.back{position:absolute;left:20rpx;top:0;bottom:0;width:80rpx;display:flex;align-items:center;justify-content:center}.back image{width:36rpx;height:36rpx}
/* 二级页面导航标题也统一,组件隔离下需在组件内部设置字体。 */
.title { font-family:'Kaiti SC','STKaiti','KaiTi',cursive; font-weight:400; font-style:normal; letter-spacing:1rpx; }
@import '../../styles/typography.wxss';
+1 -1
View File
@@ -15,6 +15,6 @@ Component({
var pages = getCurrentPages(); var pages = getCurrentPages();
// 当前主页面重复点击不重新加载。 // 当前主页面重复点击不重新加载。
if (pages.length && pages[pages.length - 1].route === paths[tab]) return; if (pages.length && pages[pages.length - 1].route === paths[tab]) return;
wx.reLaunch({ url: '/' + paths[tab] }); wx.switchTab({ url: '/' + paths[tab] });
} }
} }); } });
+6 -4
View File
@@ -6,8 +6,8 @@
z-index: 900; z-index: 900;
display: flex; display: flex;
align-items: center; align-items: center;
background: #FFFDF8; background: #FFF0F5;
border-top: 1rpx solid #E9DDD0; border-top: 1rpx solid #F3DCE5;
padding: 10rpx 20rpx calc(12rpx + env(safe-area-inset-bottom)); padding: 10rpx 20rpx calc(12rpx + env(safe-area-inset-bottom));
box-sizing: border-box; box-sizing: border-box;
} }
@@ -36,6 +36,8 @@
.tab-icon image { width: 48rpx; height: 48rpx; } .tab-icon image { width: 48rpx; height: 48rpx; }
.tab-label { font-size: 23rpx; line-height: 1.4; letter-spacing: 1rpx; } .tab-label { font-size: 23rpx; line-height: 1.4; letter-spacing: 1rpx; }
.active { color: #B74768; } .active { color: #B74768; }
.active .tab-icon { background: #F4E2E3; } .active .tab-icon { background: #FADCE7; }
.active .tab-label { font-weight: 600; } .active .tab-label { font-weight: 600; }
.tab-pressed .tab-icon { background: #F3EBE1; } .tab-pressed .tab-icon { background: #FCE5ED; }
@import '../../styles/typography.wxss';
@@ -1,17 +1,32 @@
Component({ Component({
data: { visible: true, state: 'loading', message: '' }, data: { visible: false, intro: false, state: 'loading', message: '' },
lifetimes: { lifetimes: {
attached: function() { attached: function() {
this._startedAt = Date.now(); this._startedAt = Date.now();
try { this._showIntro = !wx.getStorageSync('launchIntroSeen'); } catch (e) { this._showIntro = false; }
if (this._showIntro) {
this.setData({ visible: true, intro: true });
// 设备首次展示即记住,跳转页面或再次启动都不会重复展示。
try { wx.setStorageSync('launchIntroSeen', true); } catch (e) {}
}
this._unobserve = getApp().observeAuth((state, message) => { this._unobserve = getApp().observeAuth((state, message) => {
clearTimeout(this._exitTimer); clearTimeout(this._exitTimer);
if (state === 'ready' && !this._waiting) { if (state === 'ready') {
this.setData({ visible: false }); this._retrying = false;
} else if (state === 'ready') { if (this._showIntro) {
this._exitTimer = setTimeout(() => { this.setData({ visible: false }); }, Math.max(0, 350 - (Date.now() - this._startedAt))); this._exitTimer = setTimeout(() => {
this._showIntro = false;
this.setData({ visible: false, intro: false });
}, Math.max(0, 350 - (Date.now() - this._startedAt)));
} else {
this.setData({ visible: false, intro: false });
}
} else if (state === 'error') {
this._showIntro = false;
this._retrying = true;
this.setData({ visible: true, intro: false, state: state, message: message });
} else { } else {
this._waiting = true; this.setData({ visible: !!(this._showIntro || this._retrying), intro: !!this._showIntro, state: state, message: message });
this.setData({ visible: true, state: state, message: message });
} }
}); });
}, },
@@ -1,13 +1,16 @@
<view wx:if="{{visible}}" class="launch-screen" catchtouchmove="preventMove"> <view wx:if="{{visible}}" class="launch-screen" catchtouchmove="preventMove">
<view class="launch-content"> <view class="launch-content">
<text class="launch-mark {{state === 'loading' ? 'launch-animated' : ''}}">💝</text> <block wx:if="{{intro}}">
<text class="launch-title">我们</text> <image class="launch-mark launch-animated" src="/images/share.png" mode="aspectFit" />
<text class="launch-caption">把日常,写成我们的故事</text> <text class="launch-title">我们past</text>
<text class="launch-caption">让所有的爱都有迹可循</text>
</block>
<view wx:if="{{state === 'loading'}}" class="launch-progress"><view></view><view></view><view></view></view> <view wx:if="{{state === 'loading'}}" class="launch-progress"><view></view><view></view><view></view></view>
<block wx:else> <block wx:else>
<text class="launch-title">连接暂时没有成功</text>
<text class="launch-error">{{message}}</text> <text class="launch-error">{{message}}</text>
<button class="launch-retry" bindtap="retry">重新连接</button> <button class="launch-retry" bindtap="retry">重新连接</button>
</block> </block>
</view> </view>
<text class="launch-footer">PAGES OF US</text> <text wx:if="{{intro}}" class="launch-footer">PAGES OF US</text>
</view> </view>
@@ -1,4 +1,4 @@
.launch-screen{position:fixed;top:0;right:0;bottom:0;left:0;z-index:99999;background:#FAF5EE;display:flex;align-items:center;justify-content:center;color:#352E2A;} .launch-screen{position:fixed;top:0;right:0;bottom:0;left:0;z-index:99999;background:linear-gradient(180deg, #FDE4EC 0%, #FFF0F5 48%, #FFF7FA 100%);display:flex;align-items:center;justify-content:center;color:#352E2A;}
.launch-content{display:flex;align-items:center;flex-direction:column;width:100%;padding:0 60rpx;box-sizing:border-box;transform:translateY(-40rpx);} .launch-content{display:flex;align-items:center;flex-direction:column;width:100%;padding:0 60rpx;box-sizing:border-box;transform:translateY(-40rpx);}
.launch-mark{display:block;width:152rpx;height:152rpx;line-height:152rpx;font-size:120rpx;text-align:center;} .launch-mark{display:block;width:152rpx;height:152rpx;line-height:152rpx;font-size:120rpx;text-align:center;}
.launch-animated{animation:book-breathe 1.8s ease-in-out infinite;} .launch-animated{animation:book-breathe 1.8s ease-in-out infinite;}
@@ -12,3 +12,7 @@
.launch-footer{position:absolute;bottom:calc(56rpx + env(safe-area-inset-bottom));font-size:20rpx;letter-spacing:6rpx;color:#A39386;} .launch-footer{position:absolute;bottom:calc(56rpx + env(safe-area-inset-bottom));font-size:20rpx;letter-spacing:6rpx;color:#A39386;}
@keyframes book-breathe{0%,100%{transform:translateY(0) rotate(-3deg);}50%{transform:translateY(-10rpx) rotate(1deg);}} @keyframes book-breathe{0%,100%{transform:translateY(0) rotate(-3deg);}50%{transform:translateY(-10rpx) rotate(1deg);}}
@keyframes dot-breathe{0%,100%{opacity:.25;}50%{opacity:1;}} @keyframes dot-breathe{0%,100%{opacity:.25;}50%{opacity:1;}}
@import '../../styles/button-theme.wxss';
@import '../../styles/typography.wxss';
@@ -2,3 +2,7 @@
.state button{min-height:80rpx;line-height:1.4;display:flex;align-items:center;justify-content:center;padding:20rpx 40rpx;box-sizing:border-box;flex-shrink:0;min-width:220rpx} .state button{min-height:80rpx;line-height:1.4;display:flex;align-items:center;justify-content:center;padding:20rpx 40rpx;box-sizing:border-box;flex-shrink:0;min-width:220rpx}
.state-empty { background:#FFFDF8; border:1rpx dashed #E9DDD0; border-radius:12rpx 50rpx 12rpx 50rpx; margin:20rpx 0; }.mark { background:transparent; border-radius:0; }.state button { border-radius:26rpx; }.state .desc { font-size:24rpx; line-height:1.8; } .state-empty { background:#FFFDF8; border:1rpx dashed #E9DDD0; border-radius:12rpx 50rpx 12rpx 50rpx; margin:20rpx 0; }.mark { background:transparent; border-radius:0; }.state button { border-radius:26rpx; }.state .desc { font-size:24rpx; line-height:1.8; }
@import '../../styles/button-theme.wxss';
@import '../../styles/typography.wxss';
@@ -0,0 +1,76 @@
const { api, getImageUrl } = require('../../utils/request');
const cache = require('../../utils/data-cache');
const titles = {consume:'抽一支 · 打卡',purchase:'买烟',receive:'别人递烟',receive_smoke:'收到并抽一支',adjust:'调整库存',give:'递给别人一支',undo:'撤销记录'};
Component({
properties: { detailed: Boolean },
data: { loading:true, error:false, info:null, saving:false, sheet:false, mode:'purchase', quantity:'1', packSize:'20', amount:'', note:'', viewUserId:0, lastLabel:'还没有记录', daily:[], logs:[], ownerName:'', ownerAvatar:'', ownerInitial:'我' },
lifetimes: { attached(){this.load();} },
pageLifetimes: { show(){this.load();} },
methods: {
async load(force) {
if(this._loading) return this._loading;
if(force)cache.invalidate(['smokingInfo']);
this.setData({error:false});
const target = this.data.viewUserId;
this._loading=Promise.all([api.smokingInfo(target), api.coupleInfo().catch(()=>null)]).then(([info,profile])=>{if(target===this.data.viewUserId){this._profile=profile;this.applyInfo(info);}}).catch(()=>this.setData({error:true})).finally(()=>{this._loading=null;if(target===this.data.viewUserId)this.setData({loading:false});});
return this._loading;
},
applyInfo(info) {
const logs=(info.logs||[]).map(log=>({...log,undone:log.undone === true || Number(log.undone) === 1,title:titles[log.kind]||log.kind,change:Number(log.delta)>0?'+'+log.delta:String(log.delta),time:String(log.created_at||'').slice(5,16),money:Number(log.amount_cents)>0?(Number(log.amount_cents)/100).toFixed(2):''}));
let lastLabel='还没有记录';
if(info.last_time){const minutes=Math.max(0,Math.floor((Date.now()-new Date(String(info.last_time).replace(/-/g,'/')).getTime())/60000));lastLabel=minutes<60?'距上次 '+minutes+' 分钟':minutes<1440?'距上次 '+Math.floor(minutes/60)+' 小时':'距上次 '+Math.floor(minutes/1440)+' 天';}
const map={};(info.daily||[]).forEach(row=>{map[row.day]=Number(row.count);});const daily=[];
for(let ago=6;ago>=0;ago--){const day=new Date();day.setDate(day.getDate()-ago);const key=day.getFullYear()+'-'+String(day.getMonth()+1).padStart(2,'0')+'-'+String(day.getDate()).padStart(2,'0');daily.push({day:key,label:ago===0?'今天':(day.getMonth()+1)+'/'+day.getDate(),count:map[key]||0});}
const max=Math.max(1,...daily.map(day=>day.count));daily.forEach(day=>day.height=Math.max(4,day.count/max*100));
const profile=this._profile||{};
const ownerId=Number(info.user_id || this.data.viewUserId || wx.getStorageSync('userId'));
const owner=[profile.partner1,profile.partner2].find(user=>user && Number(user.id)===ownerId)||{};
const ownerName=owner.nickname||info.owner_name||owner.username||(info.read_only?'TA':'我');
const ownerAvatar=getImageUrl(owner.avatar||info.owner_avatar||'');
this.setData({info,logs,daily,lastLabel,ownerName,ownerAvatar,ownerInitial:ownerName.slice(0,1),error:false,loading:false});
},
avatarFailed(){this.setData({ownerAvatar:''});},
retry(){this.load(true);},
openDetail(){if(!this.properties.detailed)wx.navigateTo({url:'/pages/smoking-detail/smoking-detail'});},
async switchOwner(e){if(this.data.saving)return;this.setData({viewUserId:Number(e.currentTarget.dataset.user)||0,loading:true,info:null});if(this._loading)await this._loading;await this.load();},
replenish(){if(this.data.saving)return;this.setData({sheet:true,mode:'purchase',quantity:'1',packSize:'20',amount:'',note:''});},
mode(e){if(this.data.saving)return;const mode=e.currentTarget.dataset.mode;this.setData({mode,quantity:mode==='adjust'?String(this.data.info.stock):'1',amount:'',note:''});},
input(e){if(!this.data.saving)this.setData({[e.currentTarget.dataset.field]:e.detail.value});},
close(){if(!this.data.saving)this.setData({sheet:false});},
preventMove(){},
smoke(){return this.act({kind:'consume',quantity:1});},
receiveSmoke(){return this.act({kind:'receive_smoke',quantity:1});},
giveSmoke(){
if(this.data.saving || !this.data.info || this.data.info.read_only || !this.data.info.available)return;
if(Number(this.data.info.stock)<1)return wx.showToast({title:'库存不足,请先补充库存',icon:'none'});
return this.act({kind:'give',quantity:1});
},
submit() {
const mode=this.data.mode;
const quantityText=this.data.quantity.trim(),packText=this.data.packSize.trim();
if(!/^\d+$/.test(quantityText)||(mode==='purchase'&&!/^\d+$/.test(packText)))return wx.showToast({title:'请填写整数数量',icon:'none'});
let quantity=Number(quantityText);if(mode==='purchase'){if(Number(packText)<1||Number(packText)>100)return wx.showToast({title:'每包数量应为1到100支',icon:'none'});quantity*=Number(packText);}
if(quantity>(mode==='adjust'?1000000:10000)||(mode!=='adjust'&&quantity<1))return wx.showToast({title:'数量超出范围',icon:'none'});
const money=this.data.amount.trim();if(mode==='purchase'&&money&&!/^\d+(\.\d{1,2})?$/.test(money))return wx.showToast({title:'金额最多两位小数',icon:'none'});
const amount=mode==='purchase'?Math.round(Number(money||0)*100):0;
if(amount>100000000)return wx.showToast({title:'金额超出范围',icon:'none'});
return this.act({kind:mode,quantity,amount_cents:amount,note:this.data.note.trim()});
},
undo(e){if(this.data.saving)return;wx.showModal({title:'撤销这条记录?',content:'恢复该操作前的库存变化和抽烟次数。赠出一支仅扣库存,撤销会加回一支;“收到并抽一支”会同时撤销收到和抽烟。',success:res=>{if(res.confirm)this.act({kind:'undo',original_id:Number(e.currentTarget.dataset.id),quantity:0});}});},
async act(payload) {
if(this.data.saving||!this.data.info||this.data.info.read_only||!this.data.info.available)return;
const signature=JSON.stringify(payload);
if(!this._pendingAction||this._pendingAction.signature!==signature)this._pendingAction={signature,key:'smoke-'+Date.now()+'-'+Math.random().toString(36).slice(2)};
this.setData({saving:true});
try {
const result=await api.smokingAction({...payload,request_key:this._pendingAction.key});
if(!result.success)throw result;
this._pendingAction=null;this.applyInfo(result.info);this.setData({sheet:false});this.triggerEvent('updated');wx.showToast({title:'已记录',icon:'none'});
} catch(error) {
// 明确的业务拒绝不会提交;网络不确定时保留请求标识,重试不会重复扣减。
if(error&&error._statusCode&&error._statusCode!==500)this._pendingAction=null;
wx.showToast({title:error&&error.error||'请求失败,请重试',icon:'none'});
} finally {this.setData({saving:false});}
}
}
});
@@ -0,0 +1 @@
{"component":true,"usingComponents":{}}
@@ -0,0 +1,26 @@
<view class="smoke-card">
<view class="smoke-head" bindtap="openDetail"><view><text class="smoke-eyebrow">默认事件 · 按支记录</text><text class="smoke-title">抽烟记录</text></view><text class="smoke-link" wx:if="{{!detailed}}">明细 ›</text></view>
<view class="owners" wx:if="{{detailed && info && info.partner_id}}"><view class="owner {{!viewUserId ? 'selected' : ''}}" data-user="0" bindtap="switchOwner">我的记录</view><view class="owner {{viewUserId ? 'selected' : ''}}" data-user="{{info.partner_id}}" bindtap="switchOwner">TA的记录</view></view>
<view wx:if="{{loading}}" class="smoke-hint">正在加载记录…</view>
<view wx:elif="{{error}}" class="smoke-hint" bindtap="retry">记录加载失败,点击重试</view>
<block wx:elif="{{info}}">
<view class="smoke-stats"><view><text class="smoke-number">{{info.today_count}}<text>支</text></text><text class="smoke-label">今日已抽</text></view><view><text class="smoke-number">{{info.stock}}<text>支</text></text><text class="smoke-label">剩余库存</text></view></view>
<text class="smoke-hint">{{info.read_only ? info.owner_name + ' · 仅查看' : lastLabel}}</text>
<text class="smoke-hint" wx:if="{{!info.available}}">绑定伴侣后可记录个人库存和打卡</text>
<block wx:elif="{{!info.read_only}}">
<view class="smoke-actions"><button class="smoke-primary" disabled="{{saving || info.stock < 1}}" bindtap="smoke">抽一支,打卡</button><button class="smoke-secondary" disabled="{{saving}}" bindtap="replenish">+ 补充库存</button></view>
<view class="smoke-inline-actions"><view class="smoke-inline" bindtap="receiveSmoke">{{saving ? '正在保存…' : '别人递来一支,当场抽掉 →'}}</view><view class="smoke-inline smoke-give {{saving || info.stock < 1 ? 'smoke-give-disabled' : ''}}" bindtap="giveSmoke">递给别人一支 →</view></view>
<view class="smoke-hint" wx:if="{{info.stock < 1}}">还没有库存,先录入已有的烟或补充库存</view>
</block>
<view wx:if="{{!detailed && logs.length && !info.read_only}}" class="smoke-recent"><text>{{logs[0].title}} · {{logs[0].time}}</text><text wx:if="{{!logs[0].undone && logs[0].kind !== 'undo'}}" data-id="{{logs[0].id}}" bindtap="undo">撤销</text></view>
<block wx:if="{{detailed}}">
<view class="section-title">最近七天<text>累计 {{info.total_count}} 支</text></view>
<view class="week-chart"><view class="chart-day" wx:for="{{daily}}" wx:key="day"><text>{{item.count}}</text><view class="chart-track"><view class="chart-bar" style="height:{{item.height}}%" /></view><text>{{item.label}}</text></view></view>
<view class="section-title">库存与打卡流水<text>最近 50 条</text></view>
<view class="smoke-hint" wx:if="{{!logs.length}}">从补充库存开始,记录每一次消耗</view>
<view class="ledger {{item.undone ? 'undone' : ''}}" wx:for="{{logs}}" wx:key="id"><view class="ledger-avatar"><image wx:if="{{ownerAvatar}}" src="{{ownerAvatar}}" mode="aspectFill" binderror="avatarFailed" /><text wx:else>{{ownerInitial}}</text></view><view class="ledger-main"><view class="ledger-owner">{{ownerName}}</view><text>{{item.title}}{{item.undone ? ' · 已撤销' : ''}}</text><text>{{item.time}}{{item.money ? ' · ¥' + item.money : ''}}</text><text wx:if="{{item.note}}">{{item.note}}</text></view><view class="ledger-right"><text>{{item.change}} 支 · 余 {{item.balance}}</text><text wx:if="{{!info.read_only && !item.undone && item.kind !== 'undo'}}" data-id="{{item.id}}" bindtap="undo">撤销</text></view></view>
</block>
</block>
</view>
<view class="smoke-mask" wx:if="{{sheet}}" catchtouchmove="preventMove"><view class="smoke-backdrop" bindtap="close" /><view class="smoke-sheet"><view class="sheet-grip" /><view class="sheet-head"><text>补充与调整库存</text><text bindtap="close">关闭</text></view><view class="sheet-tabs"><view class="{{mode === 'purchase' ? 'selected' : ''}}" data-mode="purchase" bindtap="mode">买烟</view><view class="{{mode === 'receive' ? 'selected' : ''}}" data-mode="receive" bindtap="mode">别人递烟</view><view class="{{mode === 'adjust' ? 'selected' : ''}}" data-mode="adjust" bindtap="mode">调整库存</view></view>
<scroll-view class="sheet-body" scroll-y><text class="field-label">{{mode === 'purchase' ? '购买包数' : (mode === 'adjust' ? '盘点后实际剩余(支)' : '收到数量(支)')}}</text><input class="smoke-input" type="number" value="{{quantity}}" data-field="quantity" bindinput="input" disabled="{{saving}}" /><block wx:if="{{mode === 'purchase'}}"><text class="field-label">每包支数</text><input class="smoke-input" type="number" value="{{packSize}}" data-field="packSize" bindinput="input" disabled="{{saving}}" /><text class="field-label">购买金额(选填,元)</text><input class="smoke-input" type="digit" value="{{amount}}" data-field="amount" bindinput="input" disabled="{{saving}}" /></block><text class="field-label">备注(选填)</text><input class="smoke-input" value="{{note}}" maxlength="200" data-field="note" bindinput="input" disabled="{{saving}}" /><text class="smoke-hint">{{mode === 'adjust' ? '库存将调整为填写的数量,不计入抽烟打卡。' : '只增加库存,不计入抽烟打卡。'}}</text></scroll-view><button class="smoke-primary sheet-save" loading="{{saving}}" disabled="{{saving}}" bindtap="submit">{{mode === 'adjust' ? '确认调整' : '确认补充'}}</button></view></view>
@@ -0,0 +1,14 @@
.smoke-card{padding:32rpx;border:1rpx solid #F1DCE4;border-radius:28rpx;background:#FFFCFD;margin-bottom:28rpx;color:#352E2A}.smoke-head{display:flex;justify-content:space-between;align-items:center}.smoke-eyebrow{display:block;font-size:21rpx;color:#A39386;margin-bottom:10rpx}.smoke-title{font-size:33rpx;font-weight:600}.smoke-link{font-size:24rpx;color:#B74768}.smoke-stats{display:flex;margin:30rpx 0 20rpx}.smoke-stats>view{flex:1}.smoke-stats>view+view{padding-left:32rpx;border-left:1rpx solid #F1DCE4}.smoke-number{display:block;font-size:54rpx;color:#B74768;line-height:1.2}.smoke-number text{font-size:23rpx;margin-left:10rpx;color:#A39386}.smoke-label{display:block;font-size:23rpx;color:#85776D;margin-top:10rpx}.smoke-hint{display:block;font-size:22rpx;color:#A39386;line-height:1.7;margin:12rpx 0}.smoke-actions{display:flex;gap:16rpx;margin-top:24rpx}.smoke-primary,.smoke-secondary{flex:1;margin:0;border-radius:20rpx;font-size:25rpx;height:88rpx;min-height:88rpx;padding:0;display:flex;align-items:center;justify-content:center;line-height:normal;box-sizing:border-box}.smoke-primary{background:#B74768;color:#FFF}.smoke-secondary{background:#FFF0F5;color:#B74768;border:1rpx solid #F1DCE4}button::after{border:0}.smoke-primary[disabled]{background:#F0E2E8;color:#A39386}.smoke-inline{font-size:22rpx;color:#B74768;padding:24rpx 0 10rpx}.smoke-recent{display:flex;justify-content:space-between;gap:16rpx;border-top:1rpx solid #F1DCE4;margin-top:20rpx;padding-top:20rpx;font-size:22rpx;color:#A39386}.smoke-recent text+text{color:#B74768}.owners,.sheet-tabs{display:flex;gap:10rpx;margin:24rpx 0}.owner,.sheet-tabs>view{flex:1;text-align:center;background:#FFF5F8;color:#85776D;padding:18rpx 8rpx;border-radius:16rpx;font-size:24rpx}.owners .selected,.sheet-tabs .selected{background:#F4E2E3;color:#B74768;font-weight:600}.section-title{font-size:27rpx;font-weight:600;display:flex;justify-content:space-between;margin:40rpx 0 22rpx}.section-title text{font-size:20rpx;color:#A39386;font-weight:400}.week-chart{display:flex;gap:10rpx}.chart-day{flex:1;text-align:center;font-size:20rpx;color:#A39386}.chart-track{height:110rpx;display:flex;align-items:flex-end;justify-content:center;margin:12rpx 0}.chart-bar{width:24rpx;border-radius:8rpx;background:#D5A6AE}.ledger{display:flex;justify-content:space-between;gap:20rpx;padding:24rpx 0;border-top:1rpx solid #F1DCE4;font-size:24rpx}.ledger-main{flex:1;min-width:0}.ledger-main text{display:block;overflow-wrap:break-word}.ledger-main text+text{font-size:21rpx;color:#A39386;margin-top:8rpx}.ledger-right{text-align:right;font-size:22rpx}.ledger-right text{display:block}.ledger-right text+text{color:#B74768;padding-top:14rpx}.undone{opacity:.55}.smoke-mask{position:fixed;inset:0;z-index:1300;display:flex;align-items:flex-end}.smoke-backdrop{position:absolute;inset:0;background:rgba(53,46,42,.35)}.smoke-sheet{position:relative;width:100%;max-height:90vh;background:#FFFCFD;border-radius:40rpx 40rpx 0 0;padding:16rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));box-sizing:border-box}.sheet-grip{width:64rpx;height:7rpx;background:#EBD5DE;border-radius:8rpx;margin:0 auto 26rpx}.sheet-head{display:flex;justify-content:space-between;font-size:30rpx;font-weight:600}.sheet-head text+text{font-size:24rpx;color:#B74768;font-weight:400}.sheet-body{height:600rpx;max-height:52vh}.field-label{display:block;font-size:24rpx;color:#85776D;margin:20rpx 0 12rpx}.smoke-input{height:84rpx;width:100%;background:#FFF5F8;border:1rpx solid #F1DCE4;border-radius:16rpx;padding:0 24rpx;box-sizing:border-box;font-size:27rpx;color:#352E2A}.sheet-save{width:100%;margin-top:24rpx}
.ledger-avatar{width:54rpx;height:54rpx;flex-shrink:0;border-radius:50%;overflow:hidden;background:#FADCE7;color:#B74768;display:flex;align-items:center;justify-content:center;font-size:24rpx}
.ledger-avatar image{width:100%;height:100%}
.ledger-owner{font-size:24rpx;color:#64424D;margin-bottom:10rpx;overflow-wrap:break-word}
@import '../../styles/button-theme.wxss';
.smoke-inline-actions{display:flex;align-items:center;gap:20rpx}
.smoke-inline-actions .smoke-inline:first-child{flex:1;min-width:0}
.smoke-give{flex-shrink:0;text-align:right}
.smoke-give-disabled{color:#A39386;opacity:.65}
@import '../../styles/typography.wxss';
+23
View File
@@ -0,0 +1,23 @@
const { tabForPage, paths } = require('../utils/navigation');
Component({
data: { active: '', hidden: false },
lifetimes: { attached: function() { this.syncActive(); } },
pageLifetimes: { show: function() { this.syncActive(); } },
methods: {
setHidden: function(hidden) { this.setData({ hidden: !!hidden }); },
syncActive: function() {
var pages = getCurrentPages();
var current = pages[pages.length - 1];
this.setData({ active: tabForPage(current && current.route), hidden: !!(current && current.data && (current.data.publishOpen || current.data.showEditProfileModal || current.data.showEditPartnerModal)) });
},
navigate: function(e) {
var tab = e.currentTarget.dataset.tab;
if (!paths[tab]) return;
var pages = getCurrentPages();
// 当前主页面重复点击不重新加载。
if (pages.length && pages[pages.length - 1].route === paths[tab]) return;
if (this._switching) return;
this._switching = true;
wx.switchTab({ url: '/' + paths[tab], complete: () => { this._switching = false; } });
}
} });
+1
View File
@@ -0,0 +1 @@
{"component":true}
+11
View File
@@ -0,0 +1,11 @@
<view class="tabs" wx:if="{{active && !hidden}}">
<view class="tab {{active === 'home' ? 'active' : ''}}" data-tab="home" bindtap="navigate" hover-class="tab-pressed" aria-label="我们" aria-role="button">
<view class="tab-icon"><image src="{{active === 'home' ? '/components/app-tabbar/icons/us-active.svg' : '/components/app-tabbar/icons/us.svg'}}" mode="aspectFit" /></view><text class="tab-label">我们</text>
</view>
<view class="tab {{active === 'records' ? 'active' : ''}}" data-tab="records" bindtap="navigate" hover-class="tab-pressed" aria-label="记录" aria-role="button">
<view class="tab-icon"><image src="{{active === 'records' ? '/components/app-tabbar/icons/journal-active.svg' : '/components/app-tabbar/icons/journal.svg'}}" mode="aspectFit" /></view><text class="tab-label">记录</text>
</view>
<view class="tab {{active === 'profile' ? 'active' : ''}}" data-tab="profile" bindtap="navigate" hover-class="tab-pressed" aria-label="我的" aria-role="button">
<view class="tab-icon"><image src="{{active === 'profile' ? '/components/app-tabbar/icons/me-active.svg' : '/components/app-tabbar/icons/me.svg'}}" mode="aspectFit" /></view><text class="tab-label">我的</text>
</view>
</view>
+43
View File
@@ -0,0 +1,43 @@
.tabs {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 900;
display: flex;
align-items: center;
background: #FFF0F5;
border-top: 1rpx solid #F3DCE5;
padding: 10rpx 20rpx calc(12rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.tab {
flex: 1;
min-width: 0;
min-height: 92rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4rpx;
padding: 4rpx 0;
color: #8B827B;
}
.tab-icon {
display: flex;
align-items: center;
justify-content: center;
width: 88rpx;
height: 54rpx;
border-radius: 32rpx;
background: transparent;
}
.tab-icon image { width: 48rpx; height: 48rpx; }
.tab-label { font-size: 23rpx; line-height: 1.4; letter-spacing: 1rpx; }
.active { color: #B74768; }
.active .tab-icon { background: #FADCE7; }
.active .tab-label { font-weight: 600; }
.tab-pressed .tab-icon { background: #FCE5ED; }
@import '../styles/typography.wxss';
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 5-7 7 7 7"/></svg>

After

Width:  |  Height:  |  Size: 185 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="#F47FA5" d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>

After

Width:  |  Height:  |  Size: 268 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none" stroke="#77545E" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="10" y="8" width="28" height="31" rx="5" fill="#F9E1E9" transform="rotate(9 24 24)" stroke="#D8B8C0"/><rect x="8" y="11" width="29" height="30" rx="5" fill="#FFFDF8"/><path d="M12 28l7-7 7 7 4-4 3 4v6H12Z" fill="#DFEEE5" stroke="#9AAF9F"/><circle cx="29" cy="19" r="3" fill="#F8B3C9" stroke="none"/><path d="M19 37h7" stroke="#D8B8C0"/></svg>

After

Width:  |  Height:  |  Size: 529 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none" stroke="#77545E" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8h23a4 4 0 0 1 4 4v27H13a5 5 0 0 1-5-5V13a5 5 0 0 1 4-5Z" fill="#FFFDF8"/><path d="M14 8v31M8 34a5 5 0 0 1 5-5h26" stroke="#BFA1AA"/><path d="M28 8v13l4-3 4 3V8" fill="#F8B3C9" stroke="#D4819B"/><path d="M20 17h4M20 23h10" stroke="#CDB4BA"/></svg>

After

Width:  |  Height:  |  Size: 439 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none" stroke="#77545E" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="10" width="30" height="30" rx="8" fill="#FFFDF8"/><path d="M17 25l6 6 12-15" stroke="#D4819B" stroke-width="3.3"/><path d="M17 8v5M30 8v5"/><circle cx="11" cy="37" r="4" fill="#F8B3C9" stroke="none"/></svg>

After

Width:  |  Height:  |  Size: 400 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none" stroke="#77545E" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M24 40V27m0 9q-9 0-12-8 9-1 12 8m0-3q8-1 11-7" stroke="#91AC9B"/><path d="M24 28C14 28 10 21 12 14c5 0 9 3 12 7 3-4 7-7 12-7 2 7-2 14-12 14Z" fill="#F8B3C9" stroke="#D4819B"/><path d="M17 16q1-8 7-10 6 2 7 10l-7 9Z" fill="#FCE4EC" stroke="#D4819B"/><path d="M7 10v4M5 12h4" stroke="#D8B8C0"/></svg>

After

Width:  |  Height:  |  Size: 486 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none" stroke="#77545E" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M18 31V13l21-5v20"/><path d="M18 19l21-5" stroke="#D4819B"/><ellipse cx="12.5" cy="33" rx="6.5" ry="5" fill="#F8B3C9" transform="rotate(-16 12.5 33)"/><ellipse cx="33.5" cy="30" rx="6.5" ry="5" fill="#F8B3C9" transform="rotate(-16 33.5 30)"/><path d="M9 12v5M6.5 14.5h5" stroke="#CDB4BA"/></svg>

After

Width:  |  Height:  |  Size: 483 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none" stroke="#77545E" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 20 10 9l11 7a25 25 0 0 1 7 0l10-7v11c3 3 4 7 3 11-1 7-9 11-17 11S9 38 7 32c-1-5 0-9 3-12Z" fill="#FFFDF8"/><path d="m13 14 5 4-5 2Zm22 0-5 4 5 2Z" fill="#F8B3C9" stroke="none"/><path d="M16 26v2M31 26v2" stroke-width="2.8"/><path d="m21 31 3 2 3-2Z" fill="#D4819B" stroke="#D4819B"/><path d="M24 33v3m0 0q-4 3-6 0m6 0q4 3 6 0M5 28l7 2M4 34l8-1M36 30l7-2M36 33l8 1" stroke-width="1.5"/></svg>

After

Width:  |  Height:  |  Size: 583 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none" stroke="#77545E" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="8" width="13" height="13" rx="4" fill="#F8D5E0"/><rect x="27" y="8" width="13" height="13" rx="4" fill="#DFEEE5"/><rect x="8" y="27" width="13" height="13" rx="4" fill="#F7E8CA"/><rect x="27" y="27" width="13" height="13" rx="4" fill="#EAE1F4"/></svg>

After

Width:  |  Height:  |  Size: 445 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48" fill="none" stroke="#77545E" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="11" width="30" height="29" rx="7" fill="#FFFDF8"/><path d="M9 20h30M17 8v8M31 8v8"/><path d="M24 34s-8-4.7-8-9a4.3 4.3 0 0 1 8-2 4.3 4.3 0 0 1 8 2c0 4.3-8 9-8 9Z" fill="#F8B3C9" stroke="#D4819B"/></svg>

After

Width:  |  Height:  |  Size: 396 B

+80 -24
View File
@@ -1,11 +1,16 @@
const { currentUserLocation } = require('../../utils/user-location');
const { photoForm, metadataRows } = require('../../utils/photo-metadata');
const { withDataCache } = require('../../utils/page-cache');
const { getLayout } = require('../../utils/layout'); const { getLayout } = require('../../utils/layout');
// pages/album/album.js - 日记式分组相册 // pages/album/album.js - 日记式分组相册
const { api, getImageUrl, API_BASE, uploadFileCompat } = require('../../utils/request'); const { api, getImageUrl, API_BASE, uploadFileCompat } = require('../../utils/request');
const { requestSubscribeAfterAction } = require('../../utils/subscribe'); const { requestSubscribeAfterAction } = require('../../utils/subscribe');
Page({ Page(withDataCache({
onResize: function() { this.setData(getLayout()); }, onResize: function() { this.setData(getLayout()); },
data: { loading: true, loadError: false, data: { loading: true, loadError: false,
batches: [], batches: [],
showPhotoEditor: false, editPhotos: [], editPhotoIdx: 0, editBatchId: '',
editForm: { taken_at: '', location: '', activity: '' }, savingPhoto: false, currentAddress: '',
albumId: null, albumId: null,
showUploadModal: false, showUploadModal: false,
uploadFilePaths: [], uploadFilePaths: [],
@@ -27,6 +32,67 @@ Page({
onShow: function() { this._visible = true; this.loadData(); }, onShow: function() { this._visible = true; this.loadData(); },
onHide: function() { this._visible = false; }, onHide: function() { this._visible = false; },
openPhotoEditor: function(e) {
const batch = this.data.batches[Number(e.currentTarget.dataset.batch)];
if (!batch || !batch.photos.length) return;
this.setData({ showPhotoEditor: true, editBatchId: batch.batch_id, editPhotos: batch.photos, editPhotoIdx: 0, editForm: photoForm(batch.photos[0]) });
this._loadCurrentAddress();
},
_loadCurrentAddress: function() {
return currentUserLocation().then(location => { this.setData({ currentAddress: location.location }); return location; });
},
onEditPhotoInput: function(e) {
const field = e.currentTarget.dataset.field;
if (['taken_at', 'location', 'activity'].indexOf(field) < 0 || this.data.savingPhoto) return;
this.setData({ editForm: { ...this.data.editForm, [field]: e.detail.value } });
},
clearPhotoDate: function() {
if (!this.data.savingPhoto) this.setData({ editForm: { ...this.data.editForm, taken_at: '' } });
},
_confirmPhotoDiscard: function(next) {
if (this.data.savingPhoto) return;
const original = photoForm(this.data.editPhotos[this.data.editPhotoIdx] || {});
if (JSON.stringify(original) === JSON.stringify(this.data.editForm)) { next(); return; }
wx.showModal({ title: '放弃本次修改?', content: '当前照片的信息还没有保存。', confirmText: '放弃修改', success: res => { if (res.confirm && !this.data.savingPhoto) next(); } });
},
selectEditPhoto: function(e) {
const index = Number(e.currentTarget.dataset.index);
if (index === this.data.editPhotoIdx || !this.data.editPhotos[index]) return;
this._confirmPhotoDiscard(() => this.setData({ editPhotoIdx: index, editForm: photoForm(this.data.editPhotos[index]) }));
},
closePhotoEditor: function() {
this._confirmPhotoDiscard(() => this.setData({ showPhotoEditor: false }));
},
toggleMetadata: function(e) {
const index = Number(e.currentTarget.dataset.batch);
if (!this.data.batches[index]) return;
this.setData({ ['batches[' + index + '].metadataExpanded']: !this.data.batches[index].metadataExpanded });
},
preventEditorMove: function() {},
savePhotoInfo: async function() {
if (this.data.savingPhoto) return;
const photo = this.data.editPhotos[this.data.editPhotoIdx];
if (!photo || !photo.id) return;
const form = { taken_at: this.data.editForm.taken_at, location: this.data.editForm.location.trim(), activity: this.data.editForm.activity.trim() };
this.setData({ savingPhoto: true });
try {
if (!form.location) Object.assign(form, await currentUserLocation());
const result = await api.photoUpdate({ id: photo.id, lat: 0, lng: 0, ...form });
if (result && result.success && result.location !== undefined) form.location = result.location;
if (!result || !result.success) throw result || { error: '保存失败' };
// 就地更新卡片和预览,不重载列表,不改变滚动位置。
const batches = this.data.batches.map(batch => {
const photos = batch.photos.map(item => String(item.id) === String(photo.id) ? { ...item, ...form } : item);
return { ...batch, photos, metadata: metadataRows(photos) };
});
const previewImages = this.data.previewImages.map(item => String(item.id) === String(photo.id) ? { ...item, ...form } : item);
this.setData({ batches, previewImages, showPhotoEditor: false });
wx.showToast({ title: '照片信息已保存', icon: 'success' });
} catch (error) {
wx.showToast({ title: (error && (error.error || error.message)) || '保存失败,请重试', icon: 'none' });
} finally { this.setData({ savingPhoto: false }); }
},
// 左滑返回相册列表 // 左滑返回相册列表
touchStartX: 0, touchStartY: 0, touchStartX: 0, touchStartY: 0,
onTouchStart: function(e) { onTouchStart: function(e) {
@@ -66,7 +132,7 @@ Page({
loadData: async function() { loadData: async function() {
if (this._pageLoading) return; if (this._pageLoading) return;
this._pageLoading = true; this._pageLoading = true;
this.setData({loading:true, loadError:false}); this.setData({loading: !this._hasCachedData, loadError:false});
try { try {
var self = this; var self = this;
var albumId = this.data.albumId; var albumId = this.data.albumId;
@@ -78,8 +144,8 @@ Page({
} }
} }
if (albumId) { if (albumId) {
// 使用 noCache 禁用缓存,确保获取最新数据 // 共享内存缓存;写操作成功后自动失效。
var photos = await api.photos(albumId, null, 'GET', true); var photos = await api.photos(albumId);
// 根据 batch_id 分组 // 根据 batch_id 分组
var batchMap = {}; var batchMap = {};
var myUid = wx.getStorageSync('userId'); var myUid = wx.getStorageSync('userId');
@@ -115,6 +181,7 @@ Page({
// 按时间排序(最新的在最前) // 按时间排序(最新的在最前)
var batches = Object.values(batchMap); var batches = Object.values(batchMap);
batches.sort(function(a, b) { return b.created_at > a.created_at ? 1 : -1; }); batches.sort(function(a, b) { return b.created_at > a.created_at ? 1 : -1; });
batches.forEach(function(batch) { batch.metadata = metadataRows(batch.photos); });
this.setData({ batches: batches }); this.setData({ batches: batches });
} else { this.setData({batches:[]}); } } else { this.setData({batches:[]}); }
} catch (e) { } catch (e) {
@@ -142,6 +209,7 @@ Page({
wx.showToast({ title: '请选择照片', icon: 'none' }); wx.showToast({ title: '请选择照片', icon: 'none' });
return; return;
} }
self._loadCurrentAddress();
var formList = paths.map(function() { return { taken_at: '', location: '', activity: '' }; }); var formList = paths.map(function() { return { taken_at: '', location: '', activity: '' }; });
self.setData({ self.setData({
showUploadModal: true, showUploadModal: true,
@@ -152,6 +220,8 @@ Page({
}); });
}, },
fail: function(err) { fail: function(err) {
// 微信选图器关闭时也会触发 fail,主动取消无需提示错误。
if (/cancel/i.test(String(err && err.errMsg || ''))) return;
console.error('Choose image failed:', err); console.error('Choose image failed:', err);
wx.showToast({ title: '选择照片失败', icon: 'none' }); wx.showToast({ title: '选择照片失败', icon: 'none' });
} }
@@ -263,17 +333,7 @@ Page({
// 自动定位:为未填写位置的照片自动获取地理位置(发布后自动显示) // 自动定位:为未填写位置的照片自动获取地理位置(发布后自动显示)
var autoLoc = null; var autoLoc = null;
var needLoc = formList.some(function(x) { return !(x.location && x.location.trim()); }); if (formList.some(function(x) { return !String(x.location || '').trim(); })) autoLoc = await currentUserLocation();
if (needLoc) {
try {
autoLoc = await new Promise(function(resolve) {
wx.getFuzzyLocation({
success: function(res) { resolve({ lat: res.latitude, lng: res.longitude }); },
fail: function() { resolve(null); }
});
});
} catch (e) { autoLoc = null; }
}
for (var i = 0; i < paths.length; i++) { for (var i = 0; i < paths.length; i++) {
wx.showLoading({ title: '上传中 ' + (i + 1) + '/' + paths.length, mask: true }); wx.showLoading({ title: '上传中 ' + (i + 1) + '/' + paths.length, mask: true });
@@ -293,7 +353,7 @@ Page({
album_id: String(albumId), album_id: String(albumId),
path: uploadedPath, path: uploadedPath,
taken_at: f.taken_at || '', taken_at: f.taken_at || '',
location: f.location || '', location: String(f.location || '').trim() || (autoLoc ? autoLoc.location : ''),
activity: f.activity || '', activity: f.activity || '',
batch_id: batchId, batch_id: batchId,
// 未手动填写位置时,自动定位并记录经纬度(发布后自动显示地址) // 未手动填写位置时,自动定位并记录经纬度(发布后自动显示地址)
@@ -426,11 +486,7 @@ Page({
if (!batch || !batch.photos || !batch.photos[imgIdx]) return; if (!batch || !batch.photos || !batch.photos[imgIdx]) return;
var allImages = []; var allImages = [];
batch.photos.forEach(function(p) { batch.photos.forEach(function(p) {
// 经纬度为0(定位失败)时不显示位置信息 allImages.push({ id: p.id, url: p.url, taken_at: p.taken_at, location: p.location || '', activity: p.activity });
var hasCoord = p.lat && p.lng && parseFloat(p.lat) !== 0 && parseFloat(p.lng) !== 0;
var loc = p.location;
if (!hasCoord) loc = '';
allImages.push({ url: p.url, taken_at: p.taken_at, location: loc, activity: p.activity });
}); });
this.setData({ showPreview: true, previewImages: allImages, previewIndex: imgIdx }); this.setData({ showPreview: true, previewImages: allImages, previewIndex: imgIdx });
}, },
@@ -464,6 +520,6 @@ Page({
}, },
goToAlbums: function() { wx.navigateBack(); }, goToAlbums: function() { wx.navigateBack(); },
goHome: function() { wx.redirectTo({ url: '/pages/index/index' }); }, goHome: function() { wx.switchTab({ url: '/pages/index/index' }); },
goProfile: function() { wx.redirectTo({ url: '/pages/profile/profile' }); } goProfile: function() { wx.switchTab({ url: '/pages/profile/profile' }); }
}); }, {"loadData": ["photos", "albumList"]}));
+47 -3
View File
@@ -18,13 +18,38 @@
<view class="moments-name-row"> <view class="moments-name-row">
<text class="moments-name">{{item.creator_name || '情侣'}}</text> <text class="moments-name">{{item.creator_name || '情侣'}}</text>
<text class="moments-count">{{item.photos.length}}张</text> <text class="moments-count">{{item.photos.length}}张</text>
<text class="moments-time">{{item.created_time}}</text> <view class="moments-more" data-batch="{{index}}" catchtap="openPhotoEditor" hover-class="moments-more-pressed" aria-role="button" aria-label="编辑照片信息"><text>•••</text></view>
</view> </view>
<text class="moments-time moments-upload-time">上传于 {{item.created_time}}</text>
<view class="moments-images"> <view class="moments-images">
<view class="album-img-wrap" wx:for="{{item.photos}}" wx:key="id" wx:for-index="imgIdx" data-batch="{{index}}" data-index="{{imgIdx}}" bindtap="onPhotoTap" bindlongpress="onPhotoLongPress"> <view class="album-img-wrap" wx:for="{{item.photos}}" wx:key="id" wx:for-index="imgIdx" data-batch="{{index}}" data-index="{{imgIdx}}" bindtap="onPhotoTap" bindlongpress="onPhotoLongPress">
<image src="{{item.url}}" mode="aspectFill" class="album-img" /> <image src="{{item.url}}" mode="aspectFill" class="album-img" />
</view> </view>
</view> </view>
<view class="moments-metadata {{item.metadataExpanded ? 'is-expanded' : ''}}" wx:if="{{item.metadata.length}}">
<block wx:for="{{item.metadata}}" wx:for-item="note" wx:for-index="noteIndex" wx:key="id">
<view class="moment-note" wx:if="{{item.metadataExpanded || noteIndex === 0}}">
<block wx:if="{{item.metadataExpanded}}">
<text class="moment-photo-label" wx:if="{{note.label}}">{{note.label}}</text>
<view class="moment-meta-row" wx:if="{{note.taken_at}}"><text class="moment-meta-label">拍摄</text><text class="moment-meta-value">{{note.taken_at}}</text></view>
<view class="moment-meta-row" wx:if="{{note.location}}"><text class="moment-meta-label">地点</text><text class="moment-meta-value">{{note.location}}</text></view>
<view class="moment-meta-row" wx:if="{{note.activity}}"><text class="moment-meta-label">故事</text><text class="moment-meta-value moment-story">{{note.activity}}</text></view>
</block>
<block wx:else>
<view class="moment-summary-line" wx:if="{{note.label || note.taken_at || note.location}}">
<text class="moment-summary-badge" wx:if="{{note.label}}">{{note.label}}</text>
<text class="moment-summary-date" wx:if="{{note.taken_at}}">{{note.taken_at}}</text>
<text class="moment-summary-location" wx:if="{{note.location}}">{{note.location}}</text>
</view>
<text class="moment-summary-story" wx:if="{{note.activity}}">{{note.activity}}</text>
</block>
</view>
</block>
<view class="moment-metadata-toggle" data-batch="{{index}}" catchtap="toggleMetadata" aria-role="button" aria-label="{{item.metadataExpanded ? '收起照片信息' : '展开照片信息'}}">
<text>{{item.metadataExpanded ? '收起详情' : (item.metadata.length > 1 ? '还有 ' + (item.metadata.length - 1) + ' 张照片信息 · 展开' : '展开详情')}}</text><text>{{item.metadataExpanded ? '⌃' : '⌄'}}</text>
</view>
</view>
<view class="moment-add-note" wx:else data-batch="{{index}}" catchtap="openPhotoEditor">+ 补充拍摄时间、地点和故事</view>
</view> </view>
</view> </view>
</view> </view>
@@ -117,11 +142,11 @@
</view> </view>
<view class="input-group"> <view class="input-group">
<label>地点</label> <label>地点</label>
<input class="input" placeholder="例如:北京颐和园" value="{{uploadForm.location}}" bindinput="onUploadInput" data-field="location" /> <input placeholder-class="handwritten-placeholder" class="input" placeholder="{{currentAddress || '留空自动使用当前地址'}}" value="{{uploadForm.location}}" bindinput="onUploadInput" data-field="location" />
</view> </view>
<view class="input-group"> <view class="input-group">
<label>在做什么</label> <label>在做什么</label>
<input class="input" placeholder="例如:第一次一起看日落" value="{{uploadForm.activity}}" bindinput="onUploadInput" data-field="activity" /> <input placeholder-class="handwritten-placeholder" class="input" placeholder="例如:第一次一起看日落" value="{{uploadForm.activity}}" bindinput="onUploadInput" data-field="activity" />
</view> </view>
</view> </view>
@@ -137,4 +162,23 @@
</view> </view>
</view> </view>
<!-- 照片信息编辑面板 -->
<view class="photo-editor-mask" wx:if="{{showPhotoEditor}}" catchtouchmove="preventEditorMove">
<view class="photo-editor-backdrop" catchtap="closePhotoEditor" />
<view class="photo-editor-sheet">
<view class="photo-editor-grip" />
<view class="photo-editor-head"><view><text class="photo-editor-title">给照片补一段故事</text><text class="photo-editor-caption">{{editPhotos.length > 1 ? '选择照片,分别记录它的时刻' : '留住这一天,也留住当时的心情'}}</text></view><view class="photo-editor-close" catchtap="closePhotoEditor" aria-role="button" aria-label="关闭">×</view></view>
<scroll-view class="photo-editor-body" scroll-y show-scrollbar="{{false}}">
<scroll-view class="photo-editor-picker" scroll-x show-scrollbar="{{false}}" wx:if="{{editPhotos.length > 1}}">
<view class="photo-editor-thumbs"><view wx:for="{{editPhotos}}" wx:key="id" class="photo-editor-thumb {{editPhotoIdx === index ? 'photo-editor-selected' : ''}}" data-index="{{index}}" catchtap="selectEditPhoto"><image src="{{item.url}}" mode="aspectFill" /><text>第 {{index + 1}} 张</text></view></view>
</scroll-view>
<view class="photo-editor-single" wx:else><image src="{{editPhotos[0].url}}" mode="aspectFill" /><text>编辑这张照片的信息</text></view>
<view class="photo-editor-field"><view class="photo-editor-label"><text>拍摄时间</text><text class="photo-editor-clear" wx:if="{{editForm.taken_at}}" catchtap="clearPhotoDate">清除</text></view><picker mode="date" value="{{editForm.taken_at}}" disabled="{{savingPhoto}}" bindchange="onEditPhotoInput" data-field="taken_at"><view class="photo-editor-input {{!editForm.taken_at ? 'photo-editor-placeholder' : ''}}">{{editForm.taken_at || '选择拍摄日期'}}<text>⌄</text></view></picker></view>
<view class="photo-editor-field"><text class="photo-editor-label">地点 · 留空使用当前地址</text><input class="photo-editor-input" placeholder="{{currentAddress || '留空自动使用当前地址'}}" placeholder-class="photo-editor-placeholder handwritten-placeholder" maxlength="200" value="{{editForm.location}}" disabled="{{savingPhoto}}" bindinput="onEditPhotoInput" data-field="location" /></view>
<view class="photo-editor-field"><text class="photo-editor-label">在做什么</text><textarea class="photo-editor-story" placeholder="例如:第一次一起看日落" placeholder-class="photo-editor-placeholder handwritten-placeholder" maxlength="200" value="{{editForm.activity}}" disabled="{{savingPhoto}}" bindinput="onEditPhotoInput" data-field="activity" fixed="{{true}}" adjust-position="{{true}}" /></view>
</scroll-view>
<view class="photo-editor-actions"><button class="photo-editor-cancel" bindtap="closePhotoEditor" disabled="{{savingPhoto}}">取消</button><button class="j-button photo-editor-save" bindtap="savePhotoInfo" loading="{{savingPhoto}}" disabled="{{savingPhoto}}">保存这张照片</button></view>
</view>
</view>
</view> </view>
+72 -1
View File
@@ -1,6 +1,6 @@
/* pages/album/album.wxss - 日记式分组相册 */ /* pages/album/album.wxss - 日记式分组相册 */
@import '../../styles/common.wxss'; @import '../../styles/common.wxss';
page { background: linear-gradient(180deg, #F4E2E3 0%, #FAF5EE 55%, #FAF5EE 100%); height: 100vh; overflow: hidden; } page { background: linear-gradient(180deg, #FDE4EC 0%, #FFF0F5 48%, #FFF7FA 100%); height: 100vh; overflow: hidden; }
scroll-view.page-bg { height: 100vh; overflow-y: scroll; -webkit-overflow-scrolling: touch; } scroll-view.page-bg { height: 100vh; overflow-y: scroll; -webkit-overflow-scrolling: touch; }
.upload-root { .upload-root {
@@ -329,3 +329,74 @@ scroll-view.page-bg { height: 100vh; overflow-y: scroll; -webkit-overflow-scroll
@import '../../styles/design.wxss'; @import '../../styles/design.wxss';
@import '../../styles/journal.wxss'; @import '../../styles/journal.wxss';
/* 卡片上的照片故事与编辑入口。 */
.moments-name-row { align-items:center; gap:14rpx; margin-bottom:0; }
.moments-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.moments-count { flex-shrink:0; }
.moments-more { margin-left:auto; flex-shrink:0; width:88rpx; height:88rpx; display:flex; align-items:center; justify-content:center; border-radius:50%; color:#85776D; font-size:28rpx; letter-spacing:3rpx; }
.moments-more-pressed { background:#FFF0F5; }
.moments-upload-time { display:block; margin:0 0 20rpx; font-size:20rpx; color:#A39386; }
.moments-images { margin-top:20rpx; }
.moments-metadata { margin-top:18rpx; padding:16rpx 20rpx; border-radius:20rpx; background:#FFF5F8; }
.moment-note + .moment-note { border-top:1rpx solid #F1DCE4; margin-top:18rpx; padding-top:18rpx; }
.moment-photo-label { display:block; font-size:20rpx; color:#B74768; margin-bottom:12rpx; }
.moment-meta-row { display:flex; align-items:flex-start; gap:18rpx; margin-top:8rpx; font-size:24rpx; line-height:1.6; }
.moment-meta-label { flex-shrink:0; color:#A39386; font-size:21rpx; padding-top:2rpx; }
.moment-meta-value { color:#62504F; overflow-wrap:break-word; min-width:0; white-space:pre-wrap; }
.moment-story { color:#352E2A; }
.moment-add-note { margin-top:22rpx; padding:16rpx 0 2rpx; border-top:1rpx dashed #F1DCE4; color:#B74768; font-size:22rpx; line-height:1.6; }
.photo-editor-mask { position:fixed; inset:0; z-index:1300; display:flex; align-items:flex-end; }
.photo-editor-backdrop { position:absolute; inset:0; background:rgba(53,46,42,.35); }
.photo-editor-sheet { position:relative; width:100%; max-height:90vh; display:flex; flex-direction:column; background:#FFFCFD; border-radius:40rpx 40rpx 0 0; padding:16rpx 32rpx calc(24rpx + env(safe-area-inset-bottom)); box-sizing:border-box; }
.photo-editor-grip { width:64rpx; height:7rpx; border-radius:8rpx; background:#EBD5DE; margin:0 auto 26rpx; }
.photo-editor-head { display:flex; align-items:flex-start; gap:20rpx; margin-bottom:24rpx; }
.photo-editor-head > view:first-child { flex:1; min-width:0; }
.photo-editor-title { display:block; font-size:33rpx; font-weight:600; color:#352E2A; }
.photo-editor-caption { display:block; margin-top:10rpx; font-size:23rpx; color:#A39386; }
.photo-editor-close { width:64rpx; height:64rpx; display:flex; align-items:center; justify-content:center; border-radius:50%; background:#FFF0F5; color:#85776D; font-size:40rpx; }
.photo-editor-body { flex:none; height:760rpx; max-height:60vh; }
.photo-editor-picker { width:100%; white-space:nowrap; margin-bottom:28rpx; }
.photo-editor-thumbs { display:flex; gap:16rpx; padding:4rpx; }
.photo-editor-thumb { flex-shrink:0; padding:6rpx; border:2rpx solid transparent; border-radius:20rpx; text-align:center; }
.photo-editor-thumb image { display:block; width:104rpx; height:104rpx; border-radius:14rpx; }
.photo-editor-thumb text { display:block; font-size:20rpx; color:#A39386; margin-top:8rpx; }
.photo-editor-selected { border-color:#B74768; background:#FFF0F5; }.photo-editor-selected text { color:#B74768; }
.photo-editor-single { display:flex; align-items:center; gap:20rpx; margin-bottom:28rpx; font-size:24rpx; color:#85776D; }.photo-editor-single image { width:100rpx; height:100rpx; border-radius:18rpx; }
.photo-editor-field { margin-bottom:24rpx; }
.photo-editor-label { display:flex; align-items:center; justify-content:space-between; color:#62504F; font-size:25rpx; margin-bottom:12rpx; }
.photo-editor-clear { color:#B74768; font-size:22rpx; padding:4rpx 12rpx; }
.photo-editor-input { display:flex; align-items:center; justify-content:space-between; width:100%; height:88rpx; padding:0 24rpx; border:1rpx solid #F1DCE4; border-radius:18rpx; background:#FFF5F8; color:#352E2A; font-size:26rpx; box-sizing:border-box; }
.photo-editor-placeholder { color:#B4A3A6; }
.photo-editor-story { width:100%; height:160rpx; padding:22rpx 24rpx; border:1rpx solid #F1DCE4; border-radius:18rpx; background:#FFF5F8; color:#352E2A; font-size:26rpx; line-height:1.6; box-sizing:border-box; }
.photo-editor-actions { display:flex; align-items:stretch; gap:20rpx; padding-top:20rpx; }
.photo-editor-cancel { flex:1; margin:0; border:1rpx solid #F1DCE4; border-radius:26rpx; background:#FFFCFD; font-size:26rpx; color:#85776D; line-height:88rpx; }
.photo-editor-save { flex:2; }
.photo-editor-actions .photo-editor-save,
.photo-editor-actions .photo-editor-cancel {
display:flex;
align-items:center;
justify-content:center;
height:96rpx;
min-height:96rpx;
padding:0;
margin:0;
line-height:normal;
font-size:27rpx;
box-sizing:border-box;
}
.photo-editor-cancel::after { border:0; }
/* 默认只显示首张照片的两行摘要,完整信息由用户展开。 */
.moment-summary-line { display:flex; align-items:center; gap:12rpx; min-width:0; font-size:22rpx; line-height:1.5; color:#85776D; }
.moment-summary-badge { flex-shrink:0; font-size:19rpx; color:#B74768; }
.moment-summary-date { flex-shrink:0; }
.moment-summary-location { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.moment-summary-story { display:block; margin-top:6rpx; font-size:24rpx; line-height:1.5; color:#62504F; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.moment-metadata-toggle { display:flex; align-items:center; justify-content:space-between; gap:12rpx; min-height:44rpx; padding-top:8rpx; color:#B74768; font-size:21rpx; line-height:1.5; }
.moments-metadata.is-expanded { padding:20rpx; }
.moments-metadata.is-expanded .moment-metadata-toggle { margin-top:10rpx; border-top:1rpx solid #F1DCE4; padding-top:14rpx; }
@import '../../styles/button-theme.wxss';
@import '../../styles/typography.wxss';
+6 -5
View File
@@ -1,7 +1,8 @@
const { withDataCache } = require('../../utils/page-cache');
const { getLayout } = require('../../utils/layout'); const { getLayout } = require('../../utils/layout');
// pages/albums/albums.js // pages/albums/albums.js
const { api, getImageUrl } = require('../../utils/request'); const { api, getImageUrl } = require('../../utils/request');
Page({ Page(withDataCache({
onResize: function() { this.setData(getLayout()); }, onResize: function() { this.setData(getLayout()); },
data: { data: {
loading: true, loadError: false, loading: true, loadError: false,
@@ -49,7 +50,7 @@ Page({
loadAlbums: async function() { loadAlbums: async function() {
if (this._loading) return; if (this._loading) return;
this._loading = true; this._loading = true;
this.setData({ loading: true, loadError: false }); this.setData({ loading: !this._hasCachedData, loadError: false });
try { try {
var list = await api.albumList(null, 'GET', true); var list = await api.albumList(null, 'GET', true);
list = (list || []).map(function(a) { list = (list || []).map(function(a) {
@@ -170,7 +171,7 @@ Page({
}); });
}, goToPublish: function() { wx.navigateTo({ url: '/pages/publish/publish?type=album' }); }, }, goToPublish: function() { wx.navigateTo({ url: '/pages/publish/publish?type=album' }); },
goHome: function() { wx.redirectTo({ url: '/pages/index/index' }); }, goHome: function() { wx.switchTab({ url: '/pages/index/index' }); },
goBack: function() { wx.navigateBack(); }, goBack: function() { wx.navigateBack(); },
goToProfile: function() { wx.redirectTo({ url: '/pages/profile/profile' }); } goToProfile: function() { wx.switchTab({ url: '/pages/profile/profile' }); }
}); }, {"loadAlbums": ["albumList"]}));
+2 -2
View File
@@ -31,11 +31,11 @@
<text class="title">编辑相册</text> <text class="title">编辑相册</text>
<view class="input-group"> <view class="input-group">
<label>相册标题</label> <label>相册标题</label>
<input class="input" placeholder="例如:旅行记录" value="{{editForm.title}}" bindinput="onEditTitleInput" /> <input placeholder-class="handwritten-placeholder" class="input" placeholder="例如:旅行记录" value="{{editForm.title}}" bindinput="onEditTitleInput" />
</view> </view>
<view class="input-group"> <view class="input-group">
<label>描述(选填)</label> <label>描述(选填)</label>
<input class="input" placeholder="简单描述这个相册" value="{{editForm.desc}}" bindinput="onEditDescInput" /> <input placeholder-class="handwritten-placeholder" class="input" placeholder="简单描述这个相册" value="{{editForm.desc}}" bindinput="onEditDescInput" />
</view> </view>
<button class="btn btn-primary" bindtap="saveAlbumEdit" hover-class="none">保存</button> <button class="btn btn-primary" bindtap="saveAlbumEdit" hover-class="none">保存</button>
</view> </view>
+5 -1
View File
@@ -1,6 +1,6 @@
/* pages/albums/albums.wxss */ /* pages/albums/albums.wxss */
@import '../../styles/common.wxss'; @import '../../styles/common.wxss';
page { background: linear-gradient(180deg, #F4E2E3 0%, #FAF5EE 55%, #FAF5EE 100%); height: 100vh; overflow: hidden; } page { background: linear-gradient(180deg, #FDE4EC 0%, #FFF0F5 48%, #FFF7FA 100%); height: 100vh; overflow: hidden; }
scroll-view.page-bg { height: 100vh; overflow-y: scroll; -webkit-overflow-scrolling: touch; } scroll-view.page-bg { height: 100vh; overflow-y: scroll; -webkit-overflow-scrolling: touch; }
@@ -126,3 +126,7 @@ scroll-view.page-bg { height: 100vh; overflow-y: scroll; -webkit-overflow-scroll
@import '../../styles/design.wxss'; @import '../../styles/design.wxss';
@import '../../styles/journal.wxss'; @import '../../styles/journal.wxss';
@import '../../styles/button-theme.wxss';
@import '../../styles/typography.wxss';
+7 -6
View File
@@ -1,10 +1,11 @@
const { withDataCache } = require('../../utils/page-cache');
const { getLayout } = require('../../utils/layout'); const { getLayout } = require('../../utils/layout');
// pages/checkin-detail/checkin-detail.js // pages/checkin-detail/checkin-detail.js
const { api, getImageUrl, API_BASE, uploadFileCompat } = require('../../utils/request'); const { api, getImageUrl, API_BASE, uploadFileCompat } = require('../../utils/request');
const { createSwipeBack } = require('../../utils/util'); const { createSwipeBack } = require('../../utils/util');
const { requestSubscribeAfterAction } = require('../../utils/subscribe'); const { requestSubscribeAfterAction } = require('../../utils/subscribe');
const COOLDOWN = 5000; const COOLDOWN = 5000;
Page({ Page(withDataCache({
onResize: function() { this.setData(getLayout()); }, onResize: function() { this.setData(getLayout()); },
data: { loading: true, loadError: false, item: null, logs: [], freqLabel: '', typeLabel: '', myStreak: 0, partnerStreak: 0, showCheckModal: false, checkPhoto: '', checkNote: '', lastActionTime: 0, detailId: 0, myAvatar: '', partnerAvatar: '', myName: '我', partnerName: 'TA' }, data: { loading: true, loadError: false, item: null, logs: [], freqLabel: '', typeLabel: '', myStreak: 0, partnerStreak: 0, showCheckModal: false, checkPhoto: '', checkNote: '', lastActionTime: 0, detailId: 0, myAvatar: '', partnerAvatar: '', myName: '我', partnerName: 'TA' },
@@ -34,7 +35,7 @@ Page({
// 从接口加载情侣头像和名称 // 从接口加载情侣头像和名称
loadCoupleInfo: function() { loadCoupleInfo: function() {
var self = this; var self = this;
api.coupleInfo().then(function(info) { return api.coupleInfo().then(function(info) {
self._applyCoupleInfo(info); self._applyCoupleInfo(info);
}).catch(function(e) { console.error(e); }); }).catch(function(e) { console.error(e); });
}, },
@@ -56,7 +57,7 @@ Page({
loadDetail: async function(id) { loadDetail: async function(id) {
if (this._pageLoading) return; if (this._pageLoading) return;
this._pageLoading = true; this._pageLoading = true;
this.setData({loading:true, loadError:false}); this.setData({loading: !this._hasCachedData, loadError:false});
try { try {
var data = await api.checkinDetail(id); var data = await api.checkinDetail(id);
var item = data.item || {}; var item = data.item || {};
@@ -188,6 +189,6 @@ Page({
}, },
goBack: function() { wx.navigateBack(); }, goBack: function() { wx.navigateBack(); },
goHome: function() { wx.redirectTo({ url: '/pages/index/index' }); }, goHome: function() { wx.switchTab({ url: '/pages/index/index' }); },
goProfile: function() { wx.redirectTo({ url: '/pages/profile/profile' }); } goProfile: function() { wx.switchTab({ url: '/pages/profile/profile' }); }
}); }, {"loadDetail": ["checkinDetail", "coupleInfo"]}));
@@ -126,7 +126,7 @@
<view class="modal" catchtap="noop" style="max-width:600rpx;"> <view class="modal" catchtap="noop" style="max-width:600rpx;">
<text class="title">{{item.icon || '✅'}} {{item.title}}</text> <text class="title">{{item.icon || '✅'}} {{item.title}}</text>
<text class="modal-subtitle">记录这一刻</text> <text class="modal-subtitle">记录这一刻</text>
<view class="input-group"><label>备注</label><textarea class="input" style="min-height:100rpx;" placeholder="一句话..." value="{{checkNote}}" bindinput="onCheckNoteInput" maxlength="60" /></view> <view class="input-group"><label>备注</label><textarea placeholder-class="handwritten-placeholder" class="input" style="min-height:100rpx;" placeholder="一句话..." value="{{checkNote}}" bindinput="onCheckNoteInput" maxlength="60" /></view>
<view class="input-group"><label>照片</label> <view class="input-group"><label>照片</label>
<view class="photo-upload-box" bindtap="chooseCheckPhoto"> <view class="photo-upload-box" bindtap="chooseCheckPhoto">
<image wx:if="{{checkPhoto}}" src="{{checkPhoto}}" mode="aspectFill" class="photo-upload-img" /> <image wx:if="{{checkPhoto}}" src="{{checkPhoto}}" mode="aspectFill" class="photo-upload-img" />
@@ -18,3 +18,7 @@
.empty-state { padding:64rpx 20rpx; font-size:25rpx; color:#85776D; text-align:center; } .empty-state { padding:64rpx 20rpx; font-size:25rpx; color:#85776D; text-align:center; }
.modal-overlay { z-index:1200; }.modal .title { display:block; color:#352E2A; text-align:left; }.modal-subtitle { text-align:left; }.modal-actions { display:flex; gap:20rpx; margin-top:28rpx; }.modal-actions .btn { flex:1; min-width:0; padding:22rpx 12rpx; } .modal-overlay { z-index:1200; }.modal .title { display:block; color:#352E2A; text-align:left; }.modal-subtitle { text-align:left; }.modal-actions { display:flex; gap:20rpx; margin-top:28rpx; }.modal-actions .btn { flex:1; min-width:0; padding:22rpx 12rpx; }
.photo-upload-box { width:160rpx; height:160rpx; border:1rpx dashed #D5C5B3; border-radius:16rpx; overflow:hidden; background:#FAF5EE; }.photo-upload-img { width:100%; height:100%; }.photo-upload-placeholder { height:100%; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:8rpx; color:#85776D; font-size:22rpx; }.photo-upload-placeholder text:first-child { font-size:44rpx; } .photo-upload-box { width:160rpx; height:160rpx; border:1rpx dashed #D5C5B3; border-radius:16rpx; overflow:hidden; background:#FAF5EE; }.photo-upload-img { width:100%; height:100%; }.photo-upload-placeholder { height:100%; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:8rpx; color:#85776D; font-size:22rpx; }.photo-upload-placeholder text:first-child { font-size:44rpx; }
@import '../../styles/button-theme.wxss';
@import '../../styles/typography.wxss';
+9 -13
View File
@@ -1,9 +1,10 @@
const { withDataCache } = require('../../utils/page-cache');
const { getLayout } = require('../../utils/layout'); const { getLayout } = require('../../utils/layout');
// Diary journal with server pagination. // Diary journal with server pagination.
const { api, getImageUrl } = require('../../utils/request'); const { api, getImageUrl } = require('../../utils/request');
const { diaryEntry } = require('../../utils/journal'); const { diaryEntry } = require('../../utils/journal');
const { saveScrollPosition, getScrollPosition } = require('../../utils/scroll-pos'); const { saveScrollPosition, getScrollPosition } = require('../../utils/scroll-pos');
Page({ Page(withDataCache({
onResize: function() { this.setData(getLayout()); }, onResize: function() { this.setData(getLayout()); },
data: { data: {
loading: true, loadError: false, loading: true, loadError: false,
@@ -22,18 +23,13 @@ Page({
this.setData({ scrollTop: getScrollPosition('diary') }); this.setData({ scrollTop: getScrollPosition('diary') });
}, },
onShow: function() { onShow: function() {
var self = this;
var savedTop = getScrollPosition('diary');
this.setData({ scrollTop: savedTop });
setTimeout(function() {
self.setData({ scrollTop: savedTop });
}, 100);
this.loadData(); this.loadData();
}, },
// 收藏 tab 切换已移除 // 收藏 tab 切换已移除
onScroll: function(e) { onScroll: function(e) {
this._scrollTop = e.detail.scrollTop;
saveScrollPosition('diary', e.detail.scrollTop); saveScrollPosition('diary', e.detail.scrollTop);
}, },
@@ -58,7 +54,7 @@ Page({
loadData: async function() { loadData: async function() {
if (this._loading) return; if (this._loading) return;
this._loading = true; this._loading = true;
this.setData({ loading: true, loadError: false }); this.setData({ loading: !this._hasCachedData, loadError: false });
try { try {
var data = await api.diaryList({ page: 1, page_size: this.data.pageSize }); var data = await api.diaryList({ page: 1, page_size: this.data.pageSize });
var items = this._formatItems(data.items || []); var items = this._formatItems(data.items || []);
@@ -185,17 +181,17 @@ Page({
goToDetail: function(e) { goToDetail: function(e) {
var id = e.currentTarget.dataset.id; var id = e.currentTarget.dataset.id;
if (!id) return; if (!id) return;
saveScrollPosition('diary', this.data.scrollTop); saveScrollPosition('diary', this._scrollTop == null ? this.data.scrollTop : this._scrollTop);
wx.navigateTo({ url: '/pages/diary_detail/diary_detail?id=' + id }); wx.navigateTo({ url: '/pages/diary_detail/diary_detail?id=' + id });
}, },
_savePosBeforeLeave: function() { _savePosBeforeLeave: function() {
saveScrollPosition('diary', this.data.scrollTop); saveScrollPosition('diary', this._scrollTop == null ? this.data.scrollTop : this._scrollTop);
}, },
openJournalEntry: function(e) { this.goToDetail(e); }, openJournalEntry: function(e) { this.goToDetail(e); },
goToPublish: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/publish/publish?type=diary' }); }, goToPublish: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/publish/publish?type=diary' }); },
goHome: function() { this._savePosBeforeLeave(); wx.redirectTo({ url: '/pages/index/index' }); }, goHome: function() { this._savePosBeforeLeave(); wx.switchTab({ url: '/pages/index/index' }); },
goBack: function() { wx.navigateBack(); }, goBack: function() { wx.navigateBack(); },
goProfile: function() { this._savePosBeforeLeave(); wx.redirectTo({ url: '/pages/profile/profile' }); } goProfile: function() { this._savePosBeforeLeave(); wx.switchTab({ url: '/pages/profile/profile' }); }
}); }, {"loadData": ["diaryList", "coupleInfo"]}));
+4
View File
@@ -1,3 +1,7 @@
@import '../../styles/common.wxss'; @import '../../styles/common.wxss';
@import '../../styles/design.wxss'; @import '../../styles/design.wxss';
@import '../../styles/journal.wxss'; @import '../../styles/journal.wxss';
@import '../../styles/button-theme.wxss';
@import '../../styles/typography.wxss';
+11 -5
View File
@@ -1,6 +1,7 @@
const { withDataCache } = require('../../utils/page-cache');
const { getLayout } = require('../../utils/layout'); const { getLayout } = require('../../utils/layout');
const { api, getImageUrl } = require('../../utils/request'); const { api, getImageUrl } = require('../../utils/request');
Page({ Page(withDataCache({
noop: function() {}, noop: function() {},
onResize: function() { this.setData(getLayout()); }, onResize: function() { this.setData(getLayout()); },
data: { loadError: false, data: { loadError: false,
@@ -28,10 +29,14 @@ Page({
this.loadComments(); this.loadComments();
}, },
onShow: function() {
if (this.data.diaryId) { this.loadDiary(); this.loadComments(); }
},
loadDiary: async function() { loadDiary: async function() {
if (this._pageLoading) return; if (this._pageLoading) return;
this._pageLoading = true; this._pageLoading = true;
this.setData({loading:true, loadError:false}); this.setData({loading: !this._hasCachedData, loadError:false});
try { try {
var data = await api.diaryDetail(this.data.diaryId); var data = await api.diaryDetail(this.data.diaryId);
data = this._formatDiary(data); data = this._formatDiary(data);
@@ -68,6 +73,7 @@ Page({
}, },
loadComments: async function() { loadComments: async function() {
this.setData({ commentsError: false });
try { try {
var data = await api.diaryCommentList({ diary_id: this.data.diaryId }); var data = await api.diaryCommentList({ diary_id: this.data.diaryId });
if (data && data.length > 0) { if (data && data.length > 0) {
@@ -82,7 +88,7 @@ Page({
}); });
} }
this.setData({ comments: data || [] }); this.setData({ comments: data || [] });
} catch (e) { console.error(e); } } catch (e) { this.setData({ commentsError: true }); console.error(e); }
}, },
onShareTap: function() { onShareTap: function() {
@@ -226,5 +232,5 @@ Page({
}, },
goBack: function() { wx.navigateBack(); }, goBack: function() { wx.navigateBack(); },
goHome: function() { wx.redirectTo({ url: '/pages/index/index' }); } goHome: function() { wx.switchTab({ url: '/pages/index/index' }); }
}); }, {"loadDiary": ["diaryDetail"], "loadComments": ["diaryCommentList"]}));
+1 -1
View File
@@ -79,7 +79,7 @@
<text class="modal-title">{{replyTo ? '回复 @' + replyTo.nickname : '发表评论'}}</text> <text class="modal-title">{{replyTo ? '回复 @' + replyTo.nickname : '发表评论'}}</text>
<text class="modal-send" catchtap="submitComment">发送</text> <text class="modal-send" catchtap="submitComment">发送</text>
</view> </view>
<textarea class="modal-input" placeholder="说点什么..." value="{{commentText}}" bindinput="onCommentInput" auto-focus maxlength="500" /> <textarea placeholder-class="handwritten-placeholder" class="modal-input" placeholder="说点什么..." value="{{commentText}}" bindinput="onCommentInput" auto-focus maxlength="500" />
</view> </view>
</view> </view>
@@ -15,3 +15,7 @@
.modal-mask { position:fixed; top:0; left:0; right:0; bottom:0; z-index:1200; display:flex; align-items:flex-end; }.modal-box { width:100%; border-radius:24rpx 24rpx 0 0!important; padding:28rpx 32rpx calc(28rpx + env(safe-area-inset-bottom)); }.modal-top { display:flex; align-items:center; justify-content:space-between; gap:16rpx; margin-bottom:24rpx; }.modal-cancel,.modal-send { padding:12rpx 0; font-size:26rpx; color:#85776D; }.modal-send { color:#B74768; }.modal-title { font-size:28rpx; font-weight:500; color:#352E2A; }.modal-input { width:100%; height:200rpx; background:#FAF5EE; border:1rpx solid #E9DDD0; border-radius:16rpx; padding:20rpx; font-size:27rpx; box-sizing:border-box; } .modal-mask { position:fixed; top:0; left:0; right:0; bottom:0; z-index:1200; display:flex; align-items:flex-end; }.modal-box { width:100%; border-radius:24rpx 24rpx 0 0!important; padding:28rpx 32rpx calc(28rpx + env(safe-area-inset-bottom)); }.modal-top { display:flex; align-items:center; justify-content:space-between; gap:16rpx; margin-bottom:24rpx; }.modal-cancel,.modal-send { padding:12rpx 0; font-size:26rpx; color:#85776D; }.modal-send { color:#B74768; }.modal-title { font-size:28rpx; font-weight:500; color:#352E2A; }.modal-input { width:100%; height:200rpx; background:#FAF5EE; border:1rpx solid #E9DDD0; border-radius:16rpx; padding:20rpx; font-size:27rpx; box-sizing:border-box; }
.preview-mask { position:fixed; top:0; left:0; right:0; bottom:0; z-index:9999; background:rgba(0,0,0,.95); }.preview-swiper { width:100%; height:100%; }.preview-slide { position:relative; }.preview-img { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:100%; display:block; }.preview-num { position:fixed; bottom:calc(44rpx + env(safe-area-inset-bottom)); left:0; right:0; text-align:center; color:#FFFDF8; font-size:25rpx; }.preview-close { position:fixed; top:var(--nav-height,64px); right:24rpx; width:72rpx; height:72rpx; display:flex; align-items:center; justify-content:center; }.preview-close-icon { width:34rpx; height:34rpx; filter:brightness(0) invert(1); } .preview-mask { position:fixed; top:0; left:0; right:0; bottom:0; z-index:9999; background:rgba(0,0,0,.95); }.preview-swiper { width:100%; height:100%; }.preview-slide { position:relative; }.preview-img { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:100%; display:block; }.preview-num { position:fixed; bottom:calc(44rpx + env(safe-area-inset-bottom)); left:0; right:0; text-align:center; color:#FFFDF8; font-size:25rpx; }.preview-close { position:fixed; top:var(--nav-height,64px); right:24rpx; width:72rpx; height:72rpx; display:flex; align-items:center; justify-content:center; }.preview-close-icon { width:34rpx; height:34rpx; filter:brightness(0) invert(1); }
@media (max-width:350px) { .read-page { padding-left:32rpx; padding-right:32rpx; }.bottom-bar { padding-left:32rpx; padding-right:32rpx; } } @media (max-width:350px) { .read-page { padding-left:32rpx; padding-right:32rpx; }.bottom-bar { padding-left:32rpx; padding-right:32rpx; } }
@import '../../styles/button-theme.wxss';
@import '../../styles/typography.wxss';
+3 -1
View File
@@ -6,7 +6,8 @@ var WEATHER_OPTIONS = ['☀️', '⛅', '☁️', '🌧️', '⛈️', '❄️',
Page({ Page({
onResize: function() { this.setData(getLayout()); }, onResize: function() { this.setData(getLayout()); },
data: { loading: true, loadError: false, data: {
editorContent: '', loading: true, loadError: false,
diaryId: 0, diaryId: 0,
form: { title: '', content: '' }, form: { title: '', content: '' },
tempImages: [], tempImages: [],
@@ -43,6 +44,7 @@ Page({
this.setData({ this.setData({
'form.title': title, 'form.title': title,
'form.content': content, 'form.content': content,
editorContent: content,
'form.lat': data.lat || 0, 'form.lat': data.lat || 0,
'form.lng': data.lng || 0, 'form.lng': data.lng || 0,
'form.location': data.location || '', 'form.location': data.location || '',
+2 -2
View File
@@ -6,7 +6,7 @@
<!-- 标题 --> <!-- 标题 -->
<view class="input-group"> <view class="input-group">
<label>标题(选填)</label> <label>标题(选填)</label>
<input class="input diary-title-input" placeholder="给日记起个标题..." value="{{form.title}}" bindinput="onTitleInput" maxlength="100" /> <input placeholder-class="handwritten-placeholder" class="input diary-title-input" placeholder="给日记起个标题..." value="{{form.title}}" bindinput="onTitleInput" maxlength="100" />
</view> </view>
<!-- 内容编辑器(支持换行) --> <!-- 内容编辑器(支持换行) -->
@@ -15,7 +15,7 @@
<label>内容</label> <label>内容</label>
<text class="char-count {{contentLength > 5000 ? 'char-warn' : ''}}">{{contentLength}}/5000</text> <text class="char-count {{contentLength > 5000 ? 'char-warn' : ''}}">{{contentLength}}/5000</text>
</view> </view>
<textarea class="input textarea diary-editor" placeholder="想写些什么..." value="{{form.content}}" bindinput="onContentInput" maxlength="5000" auto-height /> <textarea placeholder-class="handwritten-placeholder" class="input textarea diary-editor" placeholder="想写些什么..." value="{{editorContent}}" bindinput="onContentInput" maxlength="{{5000}}" auto-height="{{false}}" cursor-spacing="24" />
</view> </view>
<!-- 图片(可删除) --> <!-- 图片(可删除) -->
+8 -1
View File
@@ -1,7 +1,7 @@
@import '../../styles/common.wxss'; @import '../../styles/common.wxss';
.container { padding: 84rpx 32rpx 300rpx; } .container { padding: 84rpx 32rpx 300rpx; }
page { background: linear-gradient(180deg, #F4E2E3 0%, #FAF5EE 55%, #FAF5EE 100%); height: 100vh; overflow: hidden; } page { background: linear-gradient(180deg, #FDE4EC 0%, #FFF0F5 48%, #FFF7FA 100%); height: 100vh; overflow: hidden; }
/* ===== 输入框美化 ===== */ /* ===== 输入框美化 ===== */
.input-group { margin-bottom: 36rpx; } .input-group { margin-bottom: 36rpx; }
@@ -140,3 +140,10 @@ page { background: linear-gradient(180deg, #F4E2E3 0%, #FAF5EE 55%, #FAF5EE 100%
@import '../../styles/design.wxss'; @import '../../styles/design.wxss';
@import '../../styles/journal.wxss'; @import '../../styles/journal.wxss';
/* 长正文在输入框内滚动,避免输入时整页不断增高。 */
textarea.diary-editor { height:420rpx; min-height:420rpx; box-sizing:border-box; }
@import '../../styles/button-theme.wxss';
@import '../../styles/typography.wxss';
+22 -19
View File
@@ -1,36 +1,34 @@
const { withDataCache } = require('../../utils/page-cache');
const { getLayout } = require('../../utils/layout'); const { getLayout } = require('../../utils/layout');
// pages/discover/discover.js // pages/discover/discover.js
const { api } = require('../../utils/request'); const { api } = require('../../utils/request');
const { diaryEntry, albumEntry, memoryEntry } = require('../../utils/journal'); const { diaryEntry, albumEntry, memoryEntry } = require('../../utils/journal');
const { saveScrollPosition, getScrollPosition } = require('../../utils/scroll-pos'); const { saveScrollPosition, resetScrollOnTabShow } = require('../../utils/scroll-pos');
Page({ Page(withDataCache({
onResize: function() { this.setData(getLayout()); }, onResize: function() { this.setData(getLayout()); },
data: { data: {
stats: null, loading: true, loadError: false, refreshing: false, publishOpen: false, stats: null, loading: true, loadError: false, refreshing: false, publishOpen: false,
activeFilter: 'diary', filters: [{value:'diary',label:'日记'}, {value:'album',label:'相册'}, {value:'memory',label:'记忆日'}], activeFilter: 'diary', filters: [{value:'diary',label:'日记',icon:'stat-book'}, {value:'album',label:'相册',icon:'stat-album'}, {value:'memory',label:'记忆日',icon:'stat-timeline'}],
entries: [], albums: [], memories: [], entries: [], albums: [], memories: [],
scrollTop: 0 scrollTop: 0
}, },
onLoad: function() { onLoad: function() {
this.setData(getLayout()); this.setData(getLayout());
this.setData({ scrollTop: getScrollPosition('discover') }); this.setData({ scrollTop: 0 });
}, },
onShow: function() { onShow: function() {
// 延迟设置 scrollTop,确保 scroll-view 在页面显示后重新应用滚动位置 resetScrollOnTabShow(this, 'discover');
// 解决从子页面 navigateBack 返回后滚动位置丢失的问题 const tabBar = this.getTabBar && this.getTabBar();
var self = this; if (tabBar) tabBar.syncActive();
var savedTop = getScrollPosition('discover'); this._syncPublishTabBar();
this.setData({ scrollTop: savedTop });
setTimeout(function() {
self.setData({ scrollTop: savedTop });
}, 100);
this.loadStats(); this.loadStats();
}, },
// 保存滚动位置 // 保存滚动位置
onScroll: function(e) { onScroll: function(e) {
this._scrollTop = e.detail.scrollTop;
saveScrollPosition('discover', e.detail.scrollTop); saveScrollPosition('discover', e.detail.scrollTop);
}, },
@@ -42,7 +40,7 @@ Page({
loadStats: async function() { loadStats: async function() {
if (this._loading) return; if (this._loading) return;
this._loading = true; this._loading = true;
this.setData({loading:true, loadError:false}); this.setData({loading: !this._hasCachedData, loadError:false});
try { try {
const [timeline, album, diary] = await Promise.all([ const [timeline, album, diary] = await Promise.all([
@@ -68,8 +66,13 @@ Page({
onRefresh: async function() { this.setData({refreshing:true}); try { await this.loadStats(); } finally { this.setData({refreshing:false}); } }, onRefresh: async function() { this.setData({refreshing:true}); try { await this.loadStats(); } finally { this.setData({refreshing:false}); } },
selectFilter: function(e) { this.setData({activeFilter:e.currentTarget.dataset.filter}); }, selectFilter: function(e) { this.setData({activeFilter:e.currentTarget.dataset.filter}); },
showPublish: function() { this.setData({publishOpen:true}); }, _syncPublishTabBar: function() {
hidePublish: function() { this.setData({publishOpen:false}); }, const tabBar = this.getTabBar && this.getTabBar();
if (tabBar && tabBar.setHidden) tabBar.setHidden(this.data.publishOpen);
},
showPublish: function() { this.setData({publishOpen:true}); this._syncPublishTabBar(); },
hidePublish: function() { this.setData({publishOpen:false}); this._syncPublishTabBar(); },
onHide: function() { this.hidePublish(); },
noop: function() {}, preventMove: function() {}, noop: function() {}, preventMove: function() {},
_publish: function(type) { this.hidePublish(); this._savePosBeforeLeave(); wx.navigateTo({url:'/pages/publish/publish?type=' + type}); }, _publish: function(type) { this.hidePublish(); this._savePosBeforeLeave(); wx.navigateTo({url:'/pages/publish/publish?type=' + type}); },
writeDiary: function() { this._publish('diary'); }, writeDiary: function() { this._publish('diary'); },
@@ -80,13 +83,13 @@ Page({
openAlbum: function(e) { this._savePosBeforeLeave(); wx.navigateTo({url:'/pages/album/album?album_id=' + e.currentTarget.dataset.id}); }, openAlbum: function(e) { this._savePosBeforeLeave(); wx.navigateTo({url:'/pages/album/album?album_id=' + e.currentTarget.dataset.id}); },
// 跳转前保存当前滚动位置 // 跳转前保存当前滚动位置
_savePosBeforeLeave: function() { _savePosBeforeLeave: function() {
saveScrollPosition('discover', this.data.scrollTop); saveScrollPosition('discover', this._scrollTop == null ? this.data.scrollTop : this._scrollTop);
}, },
// Tab 导航(替换,不留栈) // Tab 导航(替换,不留栈)
goHome: function() { this._savePosBeforeLeave(); wx.redirectTo({ url: '/pages/index/index' }); }, goHome: function() { this._savePosBeforeLeave(); wx.switchTab({ url: '/pages/index/index' }); },
goToDiaryFromDiscover: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/diary/diary?from=discover' }); }, goToDiaryFromDiscover: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/diary/diary?from=discover' }); },
goToProfile: function() { this._savePosBeforeLeave(); wx.redirectTo({ url: '/pages/profile/profile' }); }, goToProfile: function() { this._savePosBeforeLeave(); wx.switchTab({ url: '/pages/profile/profile' }); },
// 功能入口(推入栈,支持左滑返回) // 功能入口(推入栈,支持左滑返回)
goToTimeline: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/timeline/timeline' }); }, goToTimeline: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/timeline/timeline' }); },
@@ -99,4 +102,4 @@ Page({
this._savePosBeforeLeave(); this._savePosBeforeLeave();
wx.navigateTo({ url: '/pages/period/period' }); wx.navigateTo({ url: '/pages/period/period' });
} }
}); }, {"loadStats": ["timelineList", "albumList", "diaryList"]}));
-1
View File
@@ -1,7 +1,6 @@
{ {
"navigationStyle": "custom", "navigationStyle": "custom",
"usingComponents": { "usingComponents": {
"app-tabbar": "/components/app-tabbar/app-tabbar",
"page-state": "/components/page-state/page-state" "page-state": "/components/page-state/page-state"
}, },
"enablePullDownRefresh": true "enablePullDownRefresh": true
+7 -7
View File
@@ -1,17 +1,17 @@
<import src="../../templates/journal.wxml" /> <import src="../../templates/journal.wxml" />
<launch-transition /> <launch-transition />
<view class="app-shell" style="{{layoutStyle}}"><view class="j-fixed-header"><view class="j-fixed-heading" style="height:{{barHeight}}px;padding-right:{{navSide}}px;"><text class="j-eyebrow">PAGES OF US</text><text class="j-page-title">我们的生活手账</text></view></view> <view class="app-shell j-handwritten-page" style="{{layoutStyle}}"><view class="j-fixed-header"><view class="j-fixed-heading" style="height:{{barHeight}}px;padding-right:{{navSide}}px;"><text class="j-eyebrow">PAGES OF US</text><text class="j-page-title">我们的生活手账</text></view></view>
<scroll-view class="page-bg j-main-scroll" scroll-y show-scrollbar="{{false}}" bindscroll="onScroll" scroll-top="{{scrollTop}}" refresher-enabled="{{true}}" refresher-triggered="{{refreshing}}" bindrefresherrefresh="onRefresh"> <scroll-view class="page-bg j-main-scroll" scroll-y show-scrollbar="{{false}}" bindscroll="onScroll" scroll-top="{{scrollTop}}" refresher-enabled="{{true}}" refresher-triggered="{{refreshing}}" bindrefresherrefresh="onRefresh">
<view class="j-body"><view class="records-action-row"><button class="j-button j-button-small" bindtap="showPublish">+ 记录</button></view> <view class="j-body"><view class="records-action-row"><text class="j-small-title">一起的日常</text><button class="j-button j-button-small" bindtap="showPublish">+ 记录</button></view>
<view class="j-tools records-tools"><text class="j-small-title">一起的日常</text><view class="j-tool-grid"><view bindtap="goToSongs"><image src="/images/music.svg" /><text>歌曲</text></view><view bindtap="goToCheckin"><image src="/images/check.svg" /><text>打卡</text></view><view bindtap="goToPet"><image src="/images/pet.svg" /><text>宠物</text></view><view bindtap="goToPeriod"><image src="/images/j-flower.svg" /><text>姨姨</text></view></view></view> <view class="j-tools records-tools"><view class="j-tool-grid"><view bindtap="goToSongs"><image src="/images/stat-music.svg" /><text>歌曲</text></view><view bindtap="goToCheckin"><image src="/images/stat-check.svg" /><text>打卡</text></view><view bindtap="goToPet"><image src="/images/stat-pet.svg" /><text>宠物</text></view><view bindtap="goToPeriod"><image src="/images/stat-j-flower.svg" /><text>姨姨</text></view></view></view>
<view class="j-filters"><view wx:for="{{filters}}" wx:key="value" class="j-filter {{activeFilter === item.value ? 'is-active' : ''}}" data-filter="{{item.value}}" bindtap="selectFilter">{{item.label}}</view></view> <view class="j-filters"><view wx:for="{{filters}}" wx:key="value" class="j-filter {{activeFilter === item.value ? 'is-active' : ''}}" data-filter="{{item.value}}" bindtap="selectFilter"><image src="/images/{{item.icon}}.svg" mode="aspectFit" /><text>{{item.label}}</text></view></view>
<page-state wx:if="{{loading}}" kind="loading" title="正在翻开手账" /><page-state wx:elif="{{loadError}}" kind="error" title="手账暂时没有加载成功" action="重新加载" bind:action="loadStats" /> <page-state wx:if="{{loading}}" kind="loading" title="正在翻开手账" /><page-state wx:elif="{{loadError}}" kind="error" title="手账暂时没有加载成功" action="重新加载" bind:action="loadStats" />
<block wx:else> <block wx:else>
<block wx:if="{{activeFilter === 'diary'}}"><block wx:for="{{entries}}" wx:key="id"><template is="journal-entry" data="{{entry:item}}" /></block><page-state wx:if="{{!entries.length}}" title="故事,从今天开始" description="慢慢积攒属于你们的生活片段" action="写日记" bind:action="writeDiary" /><view class="j-link j-center" wx:else bindtap="goToDiaryFromDiscover">查看全部日记 →</view></block> <block wx:if="{{activeFilter === 'diary'}}"><block wx:for="{{entries}}" wx:key="id"><template is="journal-entry" data="{{entry:item}}" /></block><page-state wx:if="{{!entries.length}}" title="故事,从今天开始" description="慢慢积攒属于你们的生活片段" action="写日记" bind:action="writeDiary" /><view class="j-link j-center" wx:else bindtap="goToDiaryFromDiscover">查看全部日记 →</view></block>
<block wx:elif="{{activeFilter === 'album'}}"><view class="j-album-list"><view class="j-album-print" wx:for="{{albums}}" wx:key="id" bindtap="openAlbum" data-id="{{item.id}}"><image wx:if="{{item.cover}}" src="{{item.cover}}" mode="aspectFill" /><view wx:else class="j-album-placeholder"><image src="/images/album.svg" /><text>还没有照片</text></view><text class="j-story-title">{{item.title}}</text><text class="j-caption">{{item.photo_count || 0}} 张照片</text></view></view><page-state wx:if="{{!albums.length}}" title="相册还空着" description="为旅行、周末或日常,留一本相册" action="创建相册" bind:action="createAlbum" /><view class="j-link j-center" wx:else bindtap="goToAlbum">管理全部相册 →</view></block> <block wx:elif="{{activeFilter === 'album'}}"><view class="j-album-list"><view class="j-album-print" wx:for="{{albums}}" wx:key="id" bindtap="openAlbum" data-id="{{item.id}}"><image wx:if="{{item.cover}}" src="{{item.cover}}" mode="aspectFill" /><view wx:else class="j-album-placeholder"><image src="/images/stat-album.svg" /><text>还没有照片</text></view><text class="j-story-title">{{item.title}}</text><text class="j-caption">{{item.photo_count || 0}} 张照片</text></view></view><page-state wx:if="{{!albums.length}}" title="相册还空着" description="为旅行、周末或日常,留一本相册" action="创建相册" bind:action="createAlbum" /><view class="j-link j-center" wx:else bindtap="goToAlbum">管理全部相册 →</view></block>
<block wx:else><view class="j-memory j-memory-list" wx:for="{{memories}}" wx:key="id" bindtap="goToTimeline"><view class="j-stamp"><text>{{item.dateMonth}}月</text><text>{{item.dateDay}}</text></view><view class="j-memory-info"><text class="j-small-title">{{item.title}}</text><text class="j-caption">{{item.date}}</text></view><text class="j-link">›</text></view><page-state wx:if="{{!memories.length}}" title="记住一个特别的日子" description="第一次见面、旅行,还有每个值得期待的日子" action="添加记忆日" bind:action="createMemory" /><view class="j-link j-center" wx:else bindtap="goToTimeline">查看全部记忆日 →</view></block> <block wx:else><view class="j-memory j-memory-list" wx:for="{{memories}}" wx:key="id" bindtap="goToTimeline"><view class="j-stamp"><text>{{item.dateMonth}}月</text><text>{{item.dateDay}}</text></view><view class="j-memory-info"><text class="j-small-title">{{item.title}}</text><text class="j-caption">{{item.date}}</text></view><text class="j-link">›</text></view><page-state wx:if="{{!memories.length}}" title="记住一个特别的日子" description="第一次见面、旅行,还有每个值得期待的日子" action="添加记忆日" bind:action="createMemory" /><view class="j-link j-center" wx:else bindtap="goToTimeline">查看全部记忆日 →</view></block>
</block> </block>
</view></scroll-view><app-tabbar /> </view></scroll-view>
<view wx:if="{{publishOpen}}" class="j-sheet-mask" bindtap="hidePublish" catchtouchmove="preventMove"><view class="j-sheet" catchtap="noop"><view class="j-sheet-grip" /><view class="j-section-head"><text>留下一页日常</text><view class="j-link" bindtap="hidePublish">关闭</view></view><view class="j-publish-grid"><view bindtap="writeDiary"><image src="/images/book.svg" /><text>写日记</text></view><view bindtap="uploadPhotos"><image src="/images/album.svg" /><text>上传照片</text></view><view bindtap="createMemory"><image src="/images/timeline.svg" /><text>记忆日</text></view></view></view></view> <view wx:if="{{publishOpen}}" class="j-sheet-mask" bindtap="hidePublish" catchtouchmove="preventMove"><view class="j-sheet" catchtap="noop"><view class="j-sheet-grip" /><view class="j-section-head"><text>留下一页日常</text><view class="j-link" bindtap="hidePublish">关闭</view></view><view class="j-publish-grid"><view bindtap="writeDiary"><image src="/images/stat-book.svg" /><text>写日记</text></view><view bindtap="uploadPhotos"><image src="/images/stat-album.svg" /><text>上传照片</text></view><view bindtap="createMemory"><image src="/images/stat-timeline.svg" /><text>记忆日</text></view></view></view></view>
</view> </view>
+15 -2
View File
@@ -5,7 +5,20 @@
.records-tools { padding-top:0; margin:0 0 32rpx; border-top:0; } .records-tools { padding-top:0; margin:0 0 32rpx; border-top:0; }
.records-tools .j-tool-grid { margin-top:12rpx; border-bottom:1rpx solid #E9DDD0; padding-bottom:12rpx; gap:12rpx; } .records-tools .j-tool-grid { margin-top:12rpx; border-bottom:1rpx solid #E9DDD0; padding-bottom:12rpx; gap:12rpx; }
.records-tools .j-tool-grid > view { flex-direction:row; gap:8rpx; background:transparent; border:0; border-radius:0; padding:20rpx 0; color:#352E2A; min-height:80rpx; } .records-tools .j-tool-grid > view { flex-direction:row; gap:8rpx; background:transparent; border:0; border-radius:0; padding:20rpx 0; color:#352E2A; min-height:80rpx; }
.records-tools .j-tool-grid image { width:32rpx; height:32rpx; flex-shrink:0; } .records-tools .j-tool-grid image { width:42rpx; height:42rpx; flex-shrink:0; }
.records-action-row { display:flex; justify-content:flex-end; margin-bottom:24rpx; } .records-action-row { display:flex; align-items:center; justify-content:space-between; gap:24rpx; margin-bottom:24rpx; }
.records-action-row .j-button { margin:0; } .records-action-row .j-button { margin:0; }
@import '../../styles/button-theme.wxss';
/* 发布抽屉使用同一套手账图标。 */
.j-publish-grid > view > image { width:48rpx; height:48rpx; }
@import '../../styles/typography.wxss';
/* 分类标签配上同款图标,并保证窄屏仍能放下三个入口。 */
.j-filters { gap:24rpx; }
.j-filters .j-filter { display:flex; align-items:center; gap:8rpx; flex-shrink:0; }
.j-filters .j-filter image { width:34rpx; height:34rpx; flex-shrink:0; }
@media (max-width:350px) { .j-filters { gap:16rpx; }.j-filters .j-filter { gap:6rpx; font-size:25rpx; } }
+2 -2
View File
@@ -101,6 +101,6 @@ Page({
}, },
goToSettings: function() { wx.navigateTo({ url: '/pages/settings/settings' }); }, goToSettings: function() { wx.navigateTo({ url: '/pages/settings/settings' }); },
goHome: function() { wx.redirectTo({ url: '/pages/index/index' }); }, goHome: function() { wx.switchTab({ url: '/pages/index/index' }); },
goToProfile: function() { wx.redirectTo({ url: '/pages/profile/profile' }); } goToProfile: function() { wx.switchTab({ url: '/pages/profile/profile' }); }
}); });
+5 -1
View File
@@ -1,6 +1,6 @@
/* pages/history/history.wxss - 操作记录优化版 */ /* pages/history/history.wxss - 操作记录优化版 */
@import '../../styles/common.wxss'; @import '../../styles/common.wxss';
page { background: linear-gradient(180deg, #F4E2E3 0%, #FAF5EE 55%, #FAF5EE 100%); height: 100vh; overflow: hidden; } page { background: linear-gradient(180deg, #FDE4EC 0%, #FFF0F5 48%, #FFF7FA 100%); height: 100vh; overflow: hidden; }
.container { padding-top: 16rpx; padding-bottom: 300rpx; } .container { padding-top: 16rpx; padding-bottom: 300rpx; }
scroll-view.page-bg { height: 100vh; overflow-y: scroll; -webkit-overflow-scrolling: touch; } scroll-view.page-bg { height: 100vh; overflow-y: scroll; -webkit-overflow-scrolling: touch; }
@@ -75,3 +75,7 @@ scroll-view.page-bg { height: 100vh; overflow-y: scroll; -webkit-overflow-scroll
@import '../../styles/design.wxss'; @import '../../styles/design.wxss';
@import '../../styles/journal.wxss'; @import '../../styles/journal.wxss';
@import '../../styles/button-theme.wxss';
@import '../../styles/typography.wxss';
+30 -23
View File
@@ -1,10 +1,13 @@
let lastLocationReport = 0;
let locationReportUser = null;
const { withDataCache } = require('../../utils/page-cache');
const { getLayout } = require('../../utils/layout'); const { getLayout } = require('../../utils/layout');
// pages/index/index.js // pages/index/index.js
const { api, getImageUrl } = require('../../utils/request'); const { api, getImageUrl } = require('../../utils/request');
const { diaryEntry } = require('../../utils/journal'); const { diaryEntry } = require('../../utils/journal');
const { saveScrollPosition, getScrollPosition } = require('../../utils/scroll-pos'); const { saveScrollPosition, resetScrollOnTabShow } = require('../../utils/scroll-pos');
Page({ Page(withDataCache({
onResize: function() { this.setData(getLayout()); }, onResize: function() { this.setData(getLayout()); },
data: { data: {
userId: null, userId: null,
@@ -42,26 +45,22 @@ Page({
this.setData({ this.setData({
userId: wx.getStorageSync('userId'), userId: wx.getStorageSync('userId'),
coupleId: wx.getStorageSync('coupleId'), coupleId: wx.getStorageSync('coupleId'),
scrollTop: getScrollPosition('index') scrollTop: 0
}); });
this.loadBg(); this.loadBg();
this.loadData(); this.loadData();
}, },
onShow: function() { onShow: function() {
var self = this; resetScrollOnTabShow(this, 'index');
var savedTop = getScrollPosition('index'); const tabBar = this.getTabBar && this.getTabBar();
if (tabBar) tabBar.syncActive();
var prevCoupleId = this.data.coupleId; var prevCoupleId = this.data.coupleId;
var curCoupleId = wx.getStorageSync('coupleId'); var curCoupleId = wx.getStorageSync('coupleId');
this.setData({ this.setData({
userId: wx.getStorageSync('userId'), userId: wx.getStorageSync('userId'),
coupleId: curCoupleId, coupleId: curCoupleId
scrollTop: savedTop
}); });
// 延迟重新设置 scrollTop,解决从子页面 navigateBack 返回后滚动位置丢失的问题
setTimeout(function() {
self.setData({ scrollTop: savedTop });
}, 100);
// 检查是否已被解绑(之前有 coupleId 但被清除了) // 检查是否已被解绑(之前有 coupleId 但被清除了)
if (prevCoupleId && !curCoupleId) { if (prevCoupleId && !curCoupleId) {
wx.reLaunch({ url: '/pages/index/index' }); wx.reLaunch({ url: '/pages/index/index' });
@@ -74,6 +73,7 @@ Page({
// 保存滚动位置 // 保存滚动位置
onScroll: function(e) { onScroll: function(e) {
this._scrollTop = e.detail.scrollTop;
saveScrollPosition('index', e.detail.scrollTop); saveScrollPosition('index', e.detail.scrollTop);
}, },
@@ -166,7 +166,8 @@ Page({
if (nb < t) nb.setFullYear(nb.getFullYear()+1); if (nb < t) nb.setFullYear(nb.getFullYear()+1);
var c = Math.ceil((nb - t) / 86400000); var c = Math.ceil((nb - t) / 86400000);
if (c === 0) return '今天!'; if (c === 0) return '今天!';
if (c === 1) return '明天'; if (c === 1) return '1天';
if (!Number.isFinite(c)) return '';
return c + '天'; return c + '天';
} }
@@ -182,6 +183,7 @@ Page({
} }
var dn = { var dn = {
coupleId: info.id,
daysCount: daysCount, daysCount: daysCount,
daysLabel: '我们在一起', daysLabel: '我们在一起',
datePrefix: '始于', datePrefix: '始于',
@@ -203,7 +205,7 @@ Page({
await this.loadStats(); await this.loadStats();
} catch (e) { } catch (e) {
this.setData({ homeError: true, relationshipKnown: false, statsLoading: false, statsError: true, recentDiary: [], recentAlbums: [], upcomingMemory: null }); this.setData({ homeError: true, relationshipKnown: !!this._hasCachedData, statsLoading: false, statsError: true });
console.error(e); console.error(e);
} finally { } finally {
@@ -212,11 +214,12 @@ Page({
}, },
loadStats: async function() { loadStats: async function() {
this.setData({ statsLoading: true, statsError: false }); this.setData({ statsLoading: !this._hasCachedData, statsError: false });
try { try {
const [timeline, album, diary, songs, checkin, pet] = await Promise.all([ const [timeline, album, diary, songs, checkin, pet] = await Promise.all([
api.timelineList(), api.albumList(), api.diaryList(), api.timelineList(), api.albumList(), api.diaryList(),
api.songsList(), api.checkinList(), api.petInfo() api.songsList(), api.checkinList(),
this.data.coupleId ? api.petInfo() : Promise.resolve({ has_pet: false })
]); ]);
var photoCount = 0; var photoCount = 0;
if (Array.isArray(album)) { if (Array.isArray(album)) {
@@ -245,11 +248,15 @@ Page({
pet: petLevel pet: petLevel
} }
}); });
} catch (e) { console.error(e); this.setData({ statsError: true, recentDiary: [], recentAlbums: [], upcomingMemory: null }); } finally { this.setData({ statsLoading: false }); } } catch (e) { console.error(e); this.setData({ statsError: true }); } finally { this.setData({ statsLoading: false }); }
}, },
// 获取本机经纬度并上报后端解析地址(后端按距离判断是否重新解析,超过5km才更新),并刷新首页双方地址显示 // 获取本机经纬度并上报后端解析地址(后端按距离判断是否重新解析,超过5km才更新),并刷新首页双方地址显示
fetchIpLocation: function() { fetchIpLocation: function() {
const locationUser = wx.getStorageSync('userId');
if (locationReportUser === locationUser && Date.now() - lastLocationReport < 300000) return;
locationReportUser = locationUser;
lastLocationReport = Date.now();
var self = this; var self = this;
var report = function(lat, lng) { var report = function(lat, lng) {
var data = {}; var data = {};
@@ -258,7 +265,7 @@ Page({
if (resp && resp.location) { if (resp && resp.location) {
self.loadData(); self.loadData();
} }
}).catch(function() {}); }).catch(function() { lastLocationReport = 0; });
}; };
// 优先用模糊定位获取经纬度(getFuzzyLocation 与 getLocation 互斥,统一用模糊定位) // 优先用模糊定位获取经纬度(getFuzzyLocation 与 getLocation 互斥,统一用模糊定位)
if (wx.getFuzzyLocation) { if (wx.getFuzzyLocation) {
@@ -279,32 +286,32 @@ Page({
// 背景图以接口返回为准 // 背景图以接口返回为准
loadBg: function() { loadBg: function() {
var self = this; var self = this;
api.getBgConfig().then(function(cfg) { return api.getBgConfig().then(function(cfg) {
self.setData({ bgImage: cfg && cfg.image ? getImageUrl(cfg.image) : '' }); self.setData({ bgImage: cfg && cfg.image ? getImageUrl(cfg.image) : '' });
}).catch(function(e){ console.warn('loadBg error:', e && (e.error || e._statusCode || e)); }); }).catch(function(e){ console.warn('loadBg error:', e && (e.error || e._statusCode || e)); });
}, },
// 跳转前保存当前滚动位置 // 跳转前保存当前滚动位置
_savePosBeforeLeave: function() { _savePosBeforeLeave: function() {
saveScrollPosition('index', this.data.scrollTop); saveScrollPosition('index', this._scrollTop == null ? this.data.scrollTop : this._scrollTop);
}, },
// 页面跳转 // 页面跳转
goToDiary: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/diary/diary' }); }, goToDiary: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/diary/diary' }); },
goToProfile: function() { this._savePosBeforeLeave(); wx.redirectTo({ url: '/pages/profile/profile' }); }, goToProfile: function() { this._savePosBeforeLeave(); wx.switchTab({ url: '/pages/profile/profile' }); },
goToTimeline: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/timeline/timeline' }); }, goToTimeline: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/timeline/timeline' }); },
goToAlbum: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/albums/albums' }); }, goToAlbum: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/albums/albums' }); },
goToSongs: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/lovewords/lovewords' }); }, goToSongs: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/lovewords/lovewords' }); },
goToCheckin: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/punch/punch' }); }, goToCheckin: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/punch/punch' }); },
goToPet: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/pet/pet' }); }, goToPet: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/pet/pet' }); },
goToPeriod: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/period/period' }); }, goToPeriod: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/period/period' }); },
bindPartner: function() { wx.reLaunch({ url: '/pages/profile/profile?bind=1' }); }, bindPartner: function() { getApp().globalData.openProfileBind = true; wx.switchTab({ url: '/pages/profile/profile' }); },
openJournalEntry: function(e) { wx.navigateTo({ url: '/pages/diary_detail/diary_detail?id=' + e.currentTarget.dataset.id }); }, openJournalEntry: function(e) { wx.navigateTo({ url: '/pages/diary_detail/diary_detail?id=' + e.currentTarget.dataset.id }); },
writeDiary: function() { wx.navigateTo({ url: '/pages/publish/publish?type=diary' }); }, writeDiary: function() { wx.navigateTo({ url: '/pages/publish/publish?type=diary' }); },
openAlbumPreview: function(e) { wx.navigateTo({ url: '/pages/album/album?album_id=' + e.currentTarget.dataset.id }); }, openAlbumPreview: function(e) { wx.navigateTo({ url: '/pages/album/album?album_id=' + e.currentTarget.dataset.id }); },
openRecentDiary: function(e) { wx.navigateTo({ url: '/pages/diary_detail/diary_detail?id=' + e.currentTarget.dataset.id }); }, openRecentDiary: function(e) { wx.navigateTo({ url: '/pages/diary_detail/diary_detail?id=' + e.currentTarget.dataset.id }); },
showMore: function() { showMore: function() {
wx.reLaunch({ url: '/pages/discover/discover' }); wx.switchTab({ url: '/pages/discover/discover' });
}, },
onPullDownRefresh: async function() { onPullDownRefresh: async function() {
await this.loadData(); await this.loadData();
@@ -339,4 +346,4 @@ Page({
imageUrl: '/images/share.png' imageUrl: '/images/share.png'
}; };
} }
}); }, {"loadData": ["coupleInfo", "timelineList", "albumList", "diaryList", "songsList", "checkinList", "petInfo", "getBgConfig"]}));
-1
View File
@@ -1,6 +1,5 @@
{ {
"usingComponents": { "usingComponents": {
"app-tabbar": "/components/app-tabbar/app-tabbar",
"page-state": "/components/page-state/page-state" "page-state": "/components/page-state/page-state"
}, },
"navigationStyle": "custom", "navigationStyle": "custom",
+22 -6
View File
@@ -1,17 +1,33 @@
<import src="../../templates/journal.wxml" /> <import src="../../templates/journal.wxml" />
<launch-transition /> <launch-transition />
<view class="app-shell" style="{{layoutStyle}}"> <view class="app-shell {{bgImage ? 'home-has-background' : ''}}" style="{{layoutStyle}}">
<view wx:if="{{bgImage}}" class="j-background"><image src="{{bgImage}}" mode="aspectFill" /><view class="j-background-wash" /></view> <view wx:if="{{bgImage}}" class="j-background"><image src="{{bgImage}}" mode="aspectFill" /><view class="j-background-wash" /></view>
<view class="home-fixed-header"><view class="home-fixed-heading" style="height:{{barHeight}}px;padding-right:{{navSide}}px;"><text class="j-eyebrow">OUR LITTLE WORLD</text><text class="j-page-title">让所有的爱有迹可循</text></view></view> <view class="home-fixed-header"><view class="home-fixed-heading" style="height:{{barHeight}}px;padding-right:{{navSide}}px;"><text class="j-eyebrow">OUR LITTLE WORLD</text><view class="home-handwritten-title"><text class="j-page-title">让所有的爱有迹可循</text></view></view></view>
<scroll-view class="page-bg home-scroll" scroll-y show-scrollbar="{{false}}" refresher-enabled="{{true}}" refresher-triggered="{{refreshing}}" bindrefresherrefresh="onRefresh" bindscroll="onScroll" scroll-top="{{scrollTop}}"> <scroll-view class="page-bg home-scroll" scroll-y show-scrollbar="{{false}}" refresher-enabled="{{true}}" refresher-triggered="{{refreshing}}" bindrefresherrefresh="onRefresh" bindscroll="onScroll" scroll-top="{{scrollTop}}">
<view class="j-body home-body"> <view class="j-body home-body">
<page-state wx:if="{{homeError}}" kind="error" title="首页加载失败" description="请检查网络后重新加载" action="重试" bind:action="loadData" /> <page-state wx:if="{{homeError}}" kind="error" title="首页加载失败" description="请检查网络后重新加载" action="重试" bind:action="loadData" />
<page-state wx:elif="{{!relationshipKnown}}" kind="loading" title="正在打开你的空间" /> <page-state wx:elif="{{!relationshipKnown}}" kind="loading" title="正在打开你的空间" />
<view wx:else class="j-cover"> <view wx:else class="j-cover">
<view class="j-tape" /><view class="j-row"><text class="j-eyebrow">{{daysLabel}}</text><image class="j-cover-heart" src="/images/heart.svg" /></view> <view class="j-tape" /><view class="j-row"><text class="j-eyebrow">{{daysLabel}}</text><image class="j-cover-heart" src="/images/home-cover-heart.svg" /></view>
<view class="j-days"><text class="j-days-number">{{daysCount}}</text><text class="j-days-unit">天</text></view><text class="j-cover-date" wx:if="{{loveStartDate}}">{{datePrefix}} {{loveStartDate}}</text> <view class="j-days"><text class="j-days-number">{{daysCount}}</text><text class="j-days-unit">天</text></view><text class="j-cover-date" wx:if="{{loveStartDate}}">{{datePrefix}} {{loveStartDate}}</text>
<view class="j-couple-line"><view class="j-avatar"><image wx:if="{{partner1AvatarUrl}}" src="{{partner1AvatarUrl}}" mode="aspectFill" /><text wx:else>{{partner1Initial}}</text></view><view class="j-avatar j-avatar-partner" wx:if="{{coupleId}}"><image wx:if="{{partner2AvatarUrl}}" src="{{partner2AvatarUrl}}" mode="aspectFill" /><text wx:else>{{partner2Initial}}</text></view><view class="j-couple-names"><text>{{partner1Name}}{{coupleId ? ' & ' + partner2Name : ''}}</text><text class="j-caption" wx:if="{{partner1Location || partner2Location}}">{{partner1Location}}{{partner2Location ? ' / ' + partner2Location : ''}}</text></view></view> <view class="home-couple-panel">
<view class="j-cover-meta" wx:if="{{coupleDistance || partner1Birthday || partner2Birthday}}"><text wx:if="{{coupleDistance}}">{{coupleDistance}}</text><text wx:if="{{partner1Birthday}}">你的生日:{{partner1Birthday}}</text><text wx:if="{{partner2Birthday}}">TA的生日:{{partner2Birthday}}</text></view> <view class="home-couple-identity">
<view class="home-couple-avatars">
<view class="j-avatar"><image wx:if="{{partner1AvatarUrl}}" src="{{partner1AvatarUrl}}" mode="aspectFill" /><text wx:else>{{partner1Initial}}</text></view>
<view class="j-avatar j-avatar-partner" wx:if="{{coupleId}}"><image wx:if="{{partner2AvatarUrl}}" src="{{partner2AvatarUrl}}" mode="aspectFill" /><text wx:else>{{partner2Initial}}</text></view>
</view>
<text class="home-couple-names">{{partner1Name}}{{coupleId ? ' & ' + partner2Name : ''}}</text>
<text wx:if="{{coupleDistance}}" class="home-distance-pill">{{coupleDistance}}</text>
</view>
<view class="home-couple-locations" wx:if="{{partner1Location || partner2Location}}">
<view class="home-location-row" wx:if="{{partner1Location}}"><text class="home-location-label">我</text><text class="home-location-text">{{partner1Location}}</text></view>
<view class="home-location-row" wx:if="{{partner2Location && coupleId}}"><text class="home-location-label home-location-partner">TA</text><text class="home-location-text">{{partner2Location}}</text></view>
</view>
<view class="home-birthday-row" wx:if="{{partner1Birthday || partner2Birthday}}">
<text class="home-birthday-item" wx:if="{{partner1Birthday}}">🎂 我的生日{{partner1Birthday === '今天!' ? '今天' : partner1Birthday}}</text>
<text class="home-birthday-item" wx:if="{{partner2Birthday}}">🎂 TA的生日{{partner2Birthday === '今天!' ? '今天' : partner2Birthday}}</text>
</view>
</view>
</view> </view>
<view class="j-invite" wx:if="{{relationshipKnown && !coupleId}}" bindtap="bindPartner"><view><text class="j-small-title">邀请另一半,共写这本手账</text><text class="j-caption">绑定后,一起收藏日常</text></view><text class="j-outline-label">邀请</text></view> <view class="j-invite" wx:if="{{relationshipKnown && !coupleId}}" bindtap="bindPartner"><view><text class="j-small-title">邀请另一半,共写这本手账</text><text class="j-caption">绑定后,一起收藏日常</text></view><text class="j-outline-label">邀请</text></view>
<view class="j-memory" wx:if="{{upcomingMemory && !homeError}}" bindtap="goToTimeline"><view class="j-stamp"><text>{{upcomingMemory.dateMonth}}月</text><text>{{upcomingMemory.dateDay}}</text></view><view class="j-memory-info"><text class="j-small-title">下一次值得期待</text><text class="j-caption">{{upcomingMemory.title}}</text></view><view class="j-countdown"><text>{{upcomingMemory.days}}</text><text>{{upcomingMemory.days === 0 ? '今天' : '天后'}}</text></view></view> <view class="j-memory" wx:if="{{upcomingMemory && !homeError}}" bindtap="goToTimeline"><view class="j-stamp"><text>{{upcomingMemory.dateMonth}}月</text><text>{{upcomingMemory.dateDay}}</text></view><view class="j-memory-info"><text class="j-small-title">下一次值得期待</text><text class="j-caption">{{upcomingMemory.title}}</text></view><view class="j-countdown"><text>{{upcomingMemory.days}}</text><text>{{upcomingMemory.days === 0 ? '今天' : '天后'}}</text></view></view>
@@ -21,5 +37,5 @@
<page-state wx:else title="故事,从今天开始" description="一句心情、一张照片,都可以成为第一页" action="留下第一篇" bind:action="writeDiary" /> <page-state wx:else title="故事,从今天开始" description="一句心情、一张照片,都可以成为第一页" action="留下第一篇" bind:action="writeDiary" />
</view> </view>
<view class="j-section" wx:if="{{recentAlbums.length && !statsError}}"><view class="j-section-head"><text>相册里的片段</text><view class="j-link" bindtap="goToAlbum">全部相册 →</view></view><view class="j-photo-strip"><view class="j-photo-print" wx:for="{{recentAlbums}}" wx:key="id" data-id="{{item.id}}" bindtap="openAlbumPreview"><image src="{{item.cover}}" mode="aspectFill" /><text>{{item.title}}</text></view></view></view> <view class="j-section" wx:if="{{recentAlbums.length && !statsError}}"><view class="j-section-head"><text>相册里的片段</text><view class="j-link" bindtap="goToAlbum">全部相册 →</view></view><view class="j-photo-strip"><view class="j-photo-print" wx:for="{{recentAlbums}}" wx:key="id" data-id="{{item.id}}" bindtap="openAlbumPreview"><image src="{{item.cover}}" mode="aspectFill" /><text>{{item.title}}</text></view></view></view>
</view></scroll-view><app-tabbar /> </view></scroll-view>
</view> </view>
+45 -1
View File
@@ -8,6 +8,50 @@
.home-body { padding-top:48rpx; } .home-body { padding-top:48rpx; }
.home-fixed-heading { display:flex; flex-direction:column; align-items:flex-start; justify-content:center; box-sizing:border-box; padding-left:44rpx; } .home-fixed-heading { display:flex; flex-direction:column; align-items:flex-start; justify-content:center; box-sizing:border-box; padding-left:44rpx; }
.home-fixed-heading .j-eyebrow { font-size:20rpx; line-height:1.2; letter-spacing:2rpx; white-space:nowrap; } .home-fixed-heading .j-eyebrow { font-size:20rpx; line-height:1.2; letter-spacing:2rpx; white-space:nowrap; }
.home-fixed-heading .j-page-title { font-size:34rpx; line-height:1.35; margin:4rpx 0 0; white-space:nowrap; } /* 用楷体的自然笔画呈现手写感,不对文字做倾斜或旋转。 */
.home-handwritten-title { position:relative; flex-shrink:0; margin-top:5rpx; }
.home-handwritten-title::before { content:''; position:absolute; left:-4rpx; right:-5rpx; bottom:2rpx; height:12rpx; background:rgba(225,133,159,.22); border-radius:8rpx 3rpx 10rpx 5rpx; }
.home-fixed-heading .home-handwritten-title .j-page-title { position:relative; font-family:'Kaiti SC','STKaiti','KaiTi',cursive; font-size:34rpx; font-weight:400; font-style:normal; line-height:1.35; letter-spacing:1rpx; margin:0; white-space:nowrap; color:#64424D; }
@media (max-width:350px) { .home-fixed-heading { padding-left:32rpx; } } @media (max-width:350px) { .home-fixed-heading { padding-left:32rpx; } }
/* 在一起天数使用参考图中的亮粉色。 */
.home-body .j-days-number { color:#F47FA5; }
/* 身份、地址、距离与生日分层呈现,长地址允许完整换行。 */
.home-couple-panel { margin-top:40rpx; padding-top:28rpx; border-top:1rpx dashed #E9DDD0; }
.home-couple-identity { display:flex; align-items:center; gap:12rpx; }
.home-couple-avatars { display:flex; align-items:center; flex-shrink:0; }
.home-couple-avatars .j-avatar { width:64rpx; height:64rpx; }
.home-couple-avatars .j-avatar-partner { margin-left:-16rpx; }
.home-couple-names { flex:1; min-width:0; font-size:26rpx; line-height:1.5; color:#64424D; overflow-wrap:break-word; }
.home-couple-locations { margin-top:22rpx; }
.home-location-row { display:flex; align-items:flex-start; gap:14rpx; margin-top:12rpx; }
.home-location-label { flex-shrink:0; width:42rpx; padding:3rpx 0; border-radius:10rpx; text-align:center; font-size:19rpx; line-height:1.5; color:#B74768; background:#F8E6ED; }
.home-location-partner { color:#526B57; background:#E4EBE3; }
.home-location-text { flex:1; min-width:0; padding-top:2rpx; font-size:23rpx; line-height:1.6; color:#85776D; overflow-wrap:break-word; word-break:break-all; }
.home-distance-pill { display:inline-block; flex-shrink:0; white-space:nowrap; padding:8rpx 14rpx; border-radius:24rpx; background:#FBE4ED; color:#B74768; font-size:22rpx; line-height:1.5; }
.home-birthday-row { display:flex; flex-wrap:wrap; align-items:center; gap:8rpx 28rpx; margin-top:18rpx; color:#A65A73; font-size:22rpx; line-height:1.6; }
@import '../../styles/button-theme.wxss';
@import '../../styles/typography.wxss';
/* 自定义照片铺满首页,降低洗白遮罩;卡片透出照片且保留阅读底色。 */
.home-has-background .j-background { z-index:0; pointer-events:none; }
.home-has-background .j-background > image { display:block; }
.home-has-background .j-background-wash {
background:linear-gradient(180deg,rgba(253,228,236,.24) 0%,rgba(255,240,245,.32) 48%,rgba(255,247,250,.42) 100%);
}
.home-has-background .home-scroll { z-index:1; }
.home-has-background .home-fixed-header { background:rgba(253,228,236,.72); }
.home-has-background .j-cover {
background:rgba(255,253,248,.78);
border-color:rgba(255,253,248,.7);
}
.home-has-background .j-story,
.home-has-background .j-memory,
.home-has-background .j-invite { background:rgba(255,253,248,.9); }
.home-has-background .j-section-head { text-shadow:0 1rpx 5rpx rgba(255,253,248,.85); }
.home-has-background .home-couple-panel { padding:24rpx; border:1rpx solid rgba(233,221,208,.65); border-radius:24rpx; background:rgba(255,253,248,.82); }
+6 -5
View File
@@ -1,7 +1,8 @@
const { withDataCache } = require('../../utils/page-cache');
const { getLayout } = require('../../utils/layout'); const { getLayout } = require('../../utils/layout');
// pages/lovewords/lovewords.js - 一起听过的歌 // pages/lovewords/lovewords.js - 一起听过的歌
const { api } = require('../../utils/request'); const { api } = require('../../utils/request');
Page({ Page(withDataCache({
onResize: function() { this.setData(getLayout()); }, onResize: function() { this.setData(getLayout()); },
data: { loading: true, loadError: false, data: { loading: true, loadError: false,
list: [], list: [],
@@ -32,7 +33,7 @@ Page({
loadData: async function() { loadData: async function() {
if (this._pageLoading) return; if (this._pageLoading) return;
this._pageLoading = true; this._pageLoading = true;
this.setData({loading:true, loadError:false}); this.setData({loading: !this._hasCachedData, loadError:false});
try { try {
const list = await api.songsList(); const list = await api.songsList();
const items = (list || []).map(function(item) { const items = (list || []).map(function(item) {
@@ -121,6 +122,6 @@ Page({
}, },
goBack: function() { wx.navigateBack(); }, goBack: function() { wx.navigateBack(); },
goHome: function() { wx.redirectTo({ url: '/pages/index/index' }); }, goHome: function() { wx.switchTab({ url: '/pages/index/index' }); },
goProfile: function() { wx.redirectTo({ url: '/pages/profile/profile' }); } goProfile: function() { wx.switchTab({ url: '/pages/profile/profile' }); }
}); }, {"loadData": ["songsList"]}));
+3 -3
View File
@@ -31,15 +31,15 @@
<text class="modal-subtitle">记录我们一起听过的歌</text> <text class="modal-subtitle">记录我们一起听过的歌</text>
<view class="input-group"> <view class="input-group">
<label>歌曲名</label> <label>歌曲名</label>
<input class="input" placeholder="输入歌曲名" value="{{form.song_name}}" bindinput="onSongNameInput" /> <input placeholder-class="handwritten-placeholder" class="input" placeholder="输入歌曲名" value="{{form.song_name}}" bindinput="onSongNameInput" />
</view> </view>
<view class="input-group"> <view class="input-group">
<label>歌手</label> <label>歌手</label>
<input class="input" placeholder="输入歌手名" value="{{form.artist}}" bindinput="onArtistInput" /> <input placeholder-class="handwritten-placeholder" class="input" placeholder="输入歌手名" value="{{form.artist}}" bindinput="onArtistInput" />
</view> </view>
<view class="input-group"> <view class="input-group">
<label>备注(选填)</label> <label>备注(选填)</label>
<input class="input" placeholder="一句话,限60字" maxlength="60" value="{{form.note}}" bindinput="onNoteInput" /> <input placeholder-class="handwritten-placeholder" class="input" placeholder="一句话,限60字" maxlength="60" value="{{form.note}}" bindinput="onNoteInput" />
</view> </view>
<button class="btn btn-primary" bindtap="submit" hover-class="none">{{isEdit ? '保存修改' : '保存'}}</button> <button class="btn btn-primary" bindtap="submit" hover-class="none">{{isEdit ? '保存修改' : '保存'}}</button>
</view> </view>
+4
View File
@@ -11,3 +11,7 @@
.song-footer { display:flex; gap:16rpx; flex-wrap:wrap; margin-top:16rpx; font-size:21rpx; color:#A39386; } .song-footer { display:flex; gap:16rpx; flex-wrap:wrap; margin-top:16rpx; font-size:21rpx; color:#A39386; }
.song-menu { width:64rpx; height:64rpx; margin-right:-12rpx; display:flex; align-items:center; justify-content:center; flex-shrink:0; }.song-menu image { width:36rpx; height:36rpx; } .song-menu { width:64rpx; height:64rpx; margin-right:-12rpx; display:flex; align-items:center; justify-content:center; flex-shrink:0; }.song-menu image { width:36rpx; height:36rpx; }
.modal-overlay { z-index:1200; }.modal-close { background:transparent; }.modal .title { display:block; text-align:left; font-size:32rpx; color:#352E2A; }.modal-subtitle { text-align:left; } .modal-overlay { z-index:1200; }.modal-close { background:transparent; }.modal .title { display:block; text-align:left; font-size:32rpx; color:#352E2A; }.modal-subtitle { text-align:left; }
@import '../../styles/button-theme.wxss';
@import '../../styles/typography.wxss';
+4 -4
View File
@@ -345,7 +345,7 @@
<view class="modal-heart">🌡️</view> <view class="modal-heart">🌡️</view>
<text class="modal-title">记录体温</text> <text class="modal-title">记录体温</text>
<view class="temp-input-row"> <view class="temp-input-row">
<input class="temp-input" type="digit" placeholder="36.5" value="{{tempValue}}" bindinput="onTempInput" /> <input placeholder-class="handwritten-placeholder" class="temp-input" type="digit" placeholder="36.5" value="{{tempValue}}" bindinput="onTempInput" />
<text class="temp-unit">°C</text> <text class="temp-unit">°C</text>
</view> </view>
<view class="modal-actions"> <view class="modal-actions">
@@ -361,7 +361,7 @@
<view class="modal-heart">⚖️</view> <view class="modal-heart">⚖️</view>
<text class="modal-title">记录体重</text> <text class="modal-title">记录体重</text>
<view class="temp-input-row"> <view class="temp-input-row">
<input class="temp-input" type="digit" placeholder="50.0" value="{{weightValue}}" bindinput="onWeightInput" /> <input placeholder-class="handwritten-placeholder" class="temp-input" type="digit" placeholder="50.0" value="{{weightValue}}" bindinput="onWeightInput" />
<text class="temp-unit">kg</text> <text class="temp-unit">kg</text>
</view> </view>
<view class="modal-actions"> <view class="modal-actions">
@@ -376,7 +376,7 @@
<view class="modal period-modal"> <view class="modal period-modal">
<view class="modal-heart">📝</view> <view class="modal-heart">📝</view>
<text class="modal-title">添加备注</text> <text class="modal-title">添加备注</text>
<textarea class="note-textarea" placeholder="记录一下今天的状态..." value="{{noteValue}}" bindinput="onNoteInput" maxlength="500" /> <textarea placeholder-class="handwritten-placeholder" class="note-textarea" placeholder="记录一下今天的状态..." value="{{noteValue}}" bindinput="onNoteInput" maxlength="500" />
<view class="modal-actions"> <view class="modal-actions">
<view class="btn btn-secondary" bindtap="closeNoteModal">取消</view> <view class="btn btn-secondary" bindtap="closeNoteModal">取消</view>
<view class="btn btn-primary" bindtap="confirmNote">确定</view> <view class="btn btn-primary" bindtap="confirmNote">确定</view>
@@ -430,7 +430,7 @@
<!-- 备注 --> <!-- 备注 -->
<view class="sex-form-row"> <view class="sex-form-row">
<text class="sex-form-label">备注</text> <text class="sex-form-label">备注</text>
<input class="sex-form-input" placeholder="选填" value="{{sexForm.remark}}" bindinput="onSexRemarkInput" /> <input placeholder-class="handwritten-placeholder" class="sex-form-input" placeholder="选填" value="{{sexForm.remark}}" bindinput="onSexRemarkInput" />
</view> </view>
</view> </view>
+5 -1
View File
@@ -1,6 +1,6 @@
/* pages/period/period.wxss - 姨姨(美柚风格日历模式) */ /* pages/period/period.wxss - 姨姨(美柚风格日历模式) */
@import '../../styles/common.wxss'; @import '../../styles/common.wxss';
page { background: linear-gradient(180deg, #F4E2E3 0%, #FAF5EE 55%, #FAF5EE 100%); height: 100vh; overflow: hidden; } page { background: linear-gradient(180deg, #FDE4EC 0%, #FFF0F5 48%, #FFF7FA 100%); height: 100vh; overflow: hidden; }
.container { padding-top: 16rpx; padding-left: 20rpx; padding-right: 20rpx; } .container { padding-top: 16rpx; padding-left: 20rpx; padding-right: 20rpx; }
scroll-view.page-bg { height: 100vh; overflow-y: scroll; -webkit-overflow-scrolling: touch; } scroll-view.page-bg { height: 100vh; overflow-y: scroll; -webkit-overflow-scrolling: touch; }
@@ -1083,3 +1083,7 @@ scroll-view.page-bg { height: 100vh; overflow-y: scroll; -webkit-overflow-scroll
.j-body .quick-event-btn .qe-icon { width:34rpx; height:34rpx; }.j-body .qe-label { color:#85776D; font-size:23rpx; } .j-body .quick-event-btn .qe-icon { width:34rpx; height:34rpx; }.j-body .qe-label { color:#85776D; font-size:23rpx; }
.j-body .quick-event-btn.active { background:#F4E2E3; border-color:#D5A6AE; }.j-body .quick-event-btn.active .qe-label { color:#B74768; } .j-body .quick-event-btn.active { background:#F4E2E3; border-color:#D5A6AE; }.j-body .quick-event-btn.active .qe-label { color:#B74768; }
.j-body .cycle-card { background:#FFFDF8; border:1rpx solid #E9DDD0; border-radius:20rpx; box-shadow:none; }.j-body .cycle-date { font-size:26rpx; }.j-body .cycle-detail { color:#85776D; } .j-body .cycle-card { background:#FFFDF8; border:1rpx solid #E9DDD0; border-radius:20rpx; box-shadow:none; }.j-body .cycle-date { font-size:26rpx; }.j-body .cycle-detail { color:#85776D; }
@import '../../styles/button-theme.wxss';
@import '../../styles/typography.wxss';
+4 -3
View File
@@ -1,8 +1,9 @@
const { withDataCache } = require('../../utils/page-cache');
const { getLayout } = require('../../utils/layout'); const { getLayout } = require('../../utils/layout');
// pages/pet/achievements.js // pages/pet/achievements.js
const { api } = require('../../utils/request'); const { api } = require('../../utils/request');
Page({ Page(withDataCache({
onResize: function() { this.setData(getLayout()); }, onResize: function() { this.setData(getLayout()); },
data: { loading: true, loadError: false, data: { loading: true, loadError: false,
achievements: [], achievements: [],
@@ -23,7 +24,7 @@ Page({
loadAchievements: async function() { loadAchievements: async function() {
if (this._pageLoading) return; if (this._pageLoading) return;
this._pageLoading = true; this._pageLoading = true;
this.setData({loading:true, loadError:false}); this.setData({loading: !this._hasCachedData, loadError:false});
try { try {
const res = await api.petAchievements(); const res = await api.petAchievements();
let list = res.achievements || []; let list = res.achievements || [];
@@ -60,4 +61,4 @@ Page({
goBack: function() { goBack: function() {
wx.navigateBack(); wx.navigateBack();
} }
}); }, {"loadAchievements": ["petAchievements"]}));
+7 -3
View File
@@ -2,7 +2,7 @@
@import '../../styles/common.wxss'; @import '../../styles/common.wxss';
page { page {
background: linear-gradient(180deg, #F4E2E3 0%, #FAF5EE 55%, #FAF5EE 100%); background: linear-gradient(180deg, #FDE4EC 0%, #FFF0F5 48%, #FFF7FA 100%);
height: 100vh; height: 100vh;
overflow: hidden; overflow: hidden;
} }
@@ -11,12 +11,12 @@ scroll-view.page-bg {
padding-top: 196rpx; padding-top: 196rpx;
overflow-y: scroll; overflow-y: scroll;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
background: linear-gradient(180deg, #F4E2E3 0%, #FAF5EE 55%, #FAF5EE 100%); background: linear-gradient(180deg, #FDE4EC 0%, #FFF0F5 48%, #FFF7FA 100%);
} }
/* ===== 顶部导航(与其他页面一致,fixed 定位与微信胶囊对齐) ===== */ /* ===== 顶部导航(与其他页面一致,fixed 定位与微信胶囊对齐) ===== */
.top-nav-card { .top-nav-card {
background: linear-gradient(180deg, #F4E2E3 0%, #FAF5EE 55%, #FAF5EE 100%); background: linear-gradient(180deg, #FDE4EC 0%, #FFF0F5 48%, #FFF7FA 100%);
} }
.nav-title { .nav-title {
font-size: 34rpx; font-size: 34rpx;
@@ -181,3 +181,7 @@ scroll-view.page-bg {
@import '../../styles/journal.wxss'; @import '../../styles/journal.wxss';
.adopt-card,.stat-card,.action-card,.dynamics-card,.ach-card { background:#FFFDF8; border:1rpx solid #E9DDD0; box-shadow:none; }.adopt-title,.ach-card-name { color:#352E2A; }.adopt-desc,.ach-card-desc { color:#85776D; }.ach-summary { background:#F4E2E3; color:#943C58; box-shadow:none; }.ach-deco { display:none; } .adopt-card,.stat-card,.action-card,.dynamics-card,.ach-card { background:#FFFDF8; border:1rpx solid #E9DDD0; box-shadow:none; }.adopt-title,.ach-card-name { color:#352E2A; }.adopt-desc,.ach-card-desc { color:#85776D; }.ach-summary { background:#F4E2E3; color:#943C58; box-shadow:none; }.ach-deco { display:none; }
@import '../../styles/button-theme.wxss';
@import '../../styles/typography.wxss';
+22 -6
View File
@@ -1,3 +1,4 @@
const { withDataCache } = require('../../utils/page-cache');
const { getLayout } = require('../../utils/layout'); const { getLayout } = require('../../utils/layout');
// pages/pet/pet.js // pages/pet/pet.js
const { api } = require('../../utils/request'); const { api } = require('../../utils/request');
@@ -18,9 +19,9 @@ const BREED_EMOJIS = {
const STAGE_NAMES = ['', '幼犬期', '少年期', '青年期', '成年期', '终极期']; const STAGE_NAMES = ['', '幼犬期', '少年期', '青年期', '成年期', '终极期'];
Page({ Page(withDataCache({
onResize: function() { this.setData(getLayout()); }, onResize: function() { this.setData(getLayout()); },
data: { loading: true, loadError: false, data: { loading: true, loadError: false, needsBinding: false,
hasPet: false, hasPet: false,
pet: null, pet: null,
daily: null, daily: null,
@@ -70,8 +71,14 @@ Page({
loadPetInfo: async function() { loadPetInfo: async function() {
if (this._pageLoading) return; if (this._pageLoading) return;
this._pageLoading = true; this._pageLoading = true;
this.setData({loading:true, loadError:false}); this.setData({loading: !this._hasCachedData, loadError:false});
try { try {
const relationship = await api.coupleInfo();
if (relationship.not_bound) {
this.setData({needsBinding:true,hasPet:false,pet:null,daily:null,achievements:[],dynamics:[],displayDynamics:[],showRename:false});
return;
}
this.setData({needsBinding:false});
const res = await api.petInfo(); const res = await api.petInfo();
if (res.has_pet && res.pet) { if (res.has_pet && res.pet) {
const pet = res.pet; const pet = res.pet;
@@ -130,8 +137,12 @@ Page({
this.setData({ hasPet: false, pet:null, daily:null, achievements:[], dynamics:[], displayDynamics:[] }); this.setData({ hasPet: false, pet:null, daily:null, achievements:[], dynamics:[], displayDynamics:[] });
} }
} catch (e) { } catch (e) {
this.setData({loadError:true}); if (e && e.error === '请先绑定情侣') {
console.error(e); this.setData({needsBinding:true,hasPet:false,pet:null,showRename:false});
} else {
this.setData({loadError:true});
console.error(e);
}
} }
finally { this._pageLoading = false; this.setData({loading:false}); } finally { this._pageLoading = false; this.setData({loading:false}); }
}, },
@@ -300,10 +311,15 @@ Page({
wx.navigateTo({ url: '/pages/pet/achievements' }); wx.navigateTo({ url: '/pages/pet/achievements' });
}, },
bindPartner: function() {
getApp().globalData.openProfileBind = true;
wx.switchTab({url:'/pages/profile/profile'});
},
// 返回 // 返回
goBack: function() { goBack: function() {
wx.navigateBack(); wx.navigateBack();
}, },
stopPropagation: function() {} stopPropagation: function() {}
}); }, {"loadPetInfo": ["coupleInfo", "petInfo"]}));
+3 -3
View File
@@ -5,7 +5,7 @@
<scroll-view class="page-bg" scroll-y show-scrollbar="{{false}}" bounces="{{true}}"> <scroll-view class="page-bg" scroll-y show-scrollbar="{{false}}" bounces="{{true}}">
<view class="c-body"> <view class="c-body">
<page-state wx:if="{{loading}}" kind="loading" title="正在加载" /><page-state wx:elif="{{loadError}}" kind="error" title="加载失败" action="重试" bind:action="loadPetInfo" /><block wx:else> <page-state wx:if="{{loading}}" kind="loading" title="正在加载" /><page-state wx:elif="{{needsBinding}}" title="绑定另一半后一起养宠物" description="邀请TA加入,一起领养和照顾你们的伙伴" action="去绑定" bind:action="bindPartner" /><page-state wx:elif="{{loadError}}" kind="error" title="加载失败" action="重试" bind:action="loadPetInfo" /><block wx:else>
<!-- ===== 未领养:领养引导 ===== --> <!-- ===== 未领养:领养引导 ===== -->
<view wx:if="{{!hasPet}}" class="adopt-section"> <view wx:if="{{!hasPet}}" class="adopt-section">
@@ -34,7 +34,7 @@
</view> </view>
</view> </view>
<view class="input-group" style="margin-top:32rpx;"> <view class="input-group" style="margin-top:32rpx;">
<input class="input" placeholder="给宠物取个名字吧" value="{{petName}}" bindinput="onNameInput" maxlength="20" /> <input placeholder-class="handwritten-placeholder" class="input" placeholder="给宠物取个名字吧" value="{{petName}}" bindinput="onNameInput" maxlength="20" />
</view> </view>
<view class="gender-select"> <view class="gender-select">
<view class="gender-option {{genderSelect==1?'active':''}}" bindtap="selectGender" data-gender="1"> <view class="gender-option {{genderSelect==1?'active':''}}" bindtap="selectGender" data-gender="1">
@@ -214,7 +214,7 @@
<view wx:if="{{showRename}}" class="modal-overlay" bindtap="hideRenameModal"> <view wx:if="{{showRename}}" class="modal-overlay" bindtap="hideRenameModal">
<view class="rename-modal" catchtap="stopPropagation"> <view class="rename-modal" catchtap="stopPropagation">
<text class="rename-modal-title">给宠物改名</text> <text class="rename-modal-title">给宠物改名</text>
<input class="rename-input" placeholder="输入新名字" placeholder-class="rename-placeholder" value="{{renameValue}}" bindinput="onRenameInput" maxlength="20" /> <input class="rename-input" placeholder="输入新名字" placeholder-class="rename-placeholder handwritten-placeholder" value="{{renameValue}}" bindinput="onRenameInput" maxlength="20" />
<view class="rename-actions"> <view class="rename-actions">
<button class="btn btn-secondary rename-btn" bindtap="hideRenameModal">取消</button> <button class="btn btn-secondary rename-btn" bindtap="hideRenameModal">取消</button>
<button class="btn btn-primary rename-btn" bindtap="doRename" loading="{{renaming}}">确定</button> <button class="btn btn-primary rename-btn" bindtap="doRename" loading="{{renaming}}">确定</button>
+4
View File
@@ -17,3 +17,7 @@
.adopt-hero { text-align:center; margin:12rpx 0 28rpx; }.adopt-hero-emoji { font-size:64rpx; }.adopt-card { background:#FFFDF8; border:1rpx solid #E9DDD0; border-radius:16rpx; padding:28rpx; }.adopt-title { display:block; font-size:31rpx; font-weight:600; color:#352E2A; text-align:center; }.adopt-desc { display:block; font-size:24rpx; line-height:1.6; color:#85776D; margin-top:14rpx; text-align:center; } .adopt-hero { text-align:center; margin:12rpx 0 28rpx; }.adopt-hero-emoji { font-size:64rpx; }.adopt-card { background:#FFFDF8; border:1rpx solid #E9DDD0; border-radius:16rpx; padding:28rpx; }.adopt-title { display:block; font-size:31rpx; font-weight:600; color:#352E2A; text-align:center; }.adopt-desc { display:block; font-size:24rpx; line-height:1.6; color:#85776D; margin-top:14rpx; text-align:center; }
.breed-select { display:grid; grid-template-columns:repeat(4,1fr); gap:12rpx; margin-top:28rpx; }.breed-option { display:flex; flex-direction:column; align-items:center; gap:10rpx; padding:18rpx 4rpx; border:1rpx solid #E9DDD0; border-radius:12rpx; }.breed-option.active { background:#F4E2E3; border-color:#D5A6AE; }.breed-emoji { font-size:35rpx; }.breed-name { font-size:22rpx; color:#65594F; }.gender-select { display:flex; gap:16rpx; margin:24rpx 0; }.gender-option { display:flex; flex:1; justify-content:center; padding:20rpx 8rpx; border:1rpx solid #E9DDD0; border-radius:12rpx; color:#85776D; font-size:25rpx; }.gender-option.active { background:#F4E2E3; border-color:#D5A6AE; color:#943C58; }.adopt-btn { width:100%; font-size:26rpx; border-radius:18rpx; padding:24rpx 12rpx; } .breed-select { display:grid; grid-template-columns:repeat(4,1fr); gap:12rpx; margin-top:28rpx; }.breed-option { display:flex; flex-direction:column; align-items:center; gap:10rpx; padding:18rpx 4rpx; border:1rpx solid #E9DDD0; border-radius:12rpx; }.breed-option.active { background:#F4E2E3; border-color:#D5A6AE; }.breed-emoji { font-size:35rpx; }.breed-name { font-size:22rpx; color:#65594F; }.gender-select { display:flex; gap:16rpx; margin:24rpx 0; }.gender-option { display:flex; flex:1; justify-content:center; padding:20rpx 8rpx; border:1rpx solid #E9DDD0; border-radius:12rpx; color:#85776D; font-size:25rpx; }.gender-option.active { background:#F4E2E3; border-color:#D5A6AE; color:#943C58; }.adopt-btn { width:100%; font-size:26rpx; border-radius:18rpx; padding:24rpx 12rpx; }
.modal-overlay { z-index:1200; }.rename-modal { width:100%; max-width:600rpx; padding:36rpx; background:#FFFDF8; border:1rpx solid #E9DDD0; border-radius:24rpx; box-sizing:border-box; }.rename-modal-title { display:block; color:#352E2A; font-weight:600; font-size:31rpx; margin-bottom:26rpx; }.rename-input { width:100%; height:84rpx; padding:0 20rpx; background:#FAF5EE; border:1rpx solid #E9DDD0; border-radius:14rpx; box-sizing:border-box; font-size:27rpx; }.rename-actions { display:flex; gap:20rpx; margin-top:28rpx; }.rename-btn { flex:1; min-width:0; padding:24rpx 10rpx; font-size:26rpx; }.rename-placeholder { color:#A39386; } .modal-overlay { z-index:1200; }.rename-modal { width:100%; max-width:600rpx; padding:36rpx; background:#FFFDF8; border:1rpx solid #E9DDD0; border-radius:24rpx; box-sizing:border-box; }.rename-modal-title { display:block; color:#352E2A; font-weight:600; font-size:31rpx; margin-bottom:26rpx; }.rename-input { width:100%; height:84rpx; padding:0 20rpx; background:#FAF5EE; border:1rpx solid #E9DDD0; border-radius:14rpx; box-sizing:border-box; font-size:27rpx; }.rename-actions { display:flex; gap:20rpx; margin-top:28rpx; }.rename-btn { flex:1; min-width:0; padding:24rpx 10rpx; font-size:26rpx; }.rename-placeholder { color:#A39386; }
@import '../../styles/button-theme.wxss';
@import '../../styles/typography.wxss';
+154 -38
View File
@@ -1,12 +1,25 @@
const { withDataCache } = require('../../utils/page-cache');
const { getLayout } = require('../../utils/layout'); const { getLayout } = require('../../utils/layout');
// pages/profile/profile.js - 与原网站一致的个人中心 // pages/profile/profile.js - 与原网站一致的个人中心
const { api, getImageUrl } = require('../../utils/request'); const { api, getImageUrl } = require('../../utils/request');
const { saveScrollPosition, getScrollPosition } = require('../../utils/scroll-pos'); const { saveScrollPosition, resetScrollOnTabShow } = require('../../utils/scroll-pos');
const { requestSubscribe } = require('../../utils/subscribe');
Page({ Page(withDataCache({
onResize: function() { this.setData(getLayout()); }, onResize: function() { this.setData(getLayout()); },
data: { data: {
scrollTop: 0, scrollTop: 0,
statsLoading:false, statsError:false,
statisticItems:[
{key:'timeline',label:'记忆日',icon:'stat-timeline',count:'—',color:'pink'},
{key:'album',label:'相册',icon:'stat-album',count:'—',color:'yellow'},
{key:'diary',label:'日记',icon:'stat-book',count:'—',color:'mint'},
{key:'songs',label:'歌曲',icon:'stat-music',count:'—',color:'blue'},
{key:'checkin',label:'打卡',icon:'stat-check',count:'—',color:'purple'},
{key:'pet',label:'宠物',icon:'stat-pet',count:'—',color:'pink'},
{key:'period',label:'姨姨',icon:'stat-j-flower',count:'',color:'pink'},
{key:'settings',label:'更多',icon:'stat-settings',count:'',color:'gray'}
],
// 我的信息 // 我的信息
myNickname: '未登录', myNickname: '未登录',
@@ -26,6 +39,7 @@ Page({
bindingKnown: false, bindingError: false, bindingKnown: false, bindingError: false,
partnerName: 'TA', partnerName: 'TA',
partnerDisplayId: '', partnerDisplayId: '',
partnerId: 0,
partnerDetail: '', partnerDetail: '',
partnerAvatarUrl: '', partnerAvatarUrl: '',
partnerInitial: '?', partnerInitial: '?',
@@ -54,8 +68,9 @@ Page({
tempLoveDate: '', tempLoveDate: '',
tempLoveStory: '', tempLoveStory: '',
// 查看TA资料(只读)弹窗 // 编辑TA资料弹窗
showEditPartnerModal: false, showEditPartnerModal: false,
partnerSaving: false, partnerAvatarUploading: false,
editPartnerNickname: '', editPartnerNickname: '',
editPartnerGender: 'male', editPartnerGender: 'male',
editPartnerGenderText: '男', editPartnerGenderText: '男',
@@ -127,6 +142,7 @@ Page({
_needRefresh: true, _needRefresh: true,
onScroll: function(e) { onScroll: function(e) {
this._scrollTop = e.detail.scrollTop;
saveScrollPosition('profile', e.detail.scrollTop); saveScrollPosition('profile', e.detail.scrollTop);
}, },
@@ -137,7 +153,7 @@ Page({
this.setData({ this.setData({
userId: wx.getStorageSync('userId'), userId: wx.getStorageSync('userId'),
coupleId: wx.getStorageSync('coupleId'), coupleId: wx.getStorageSync('coupleId'),
scrollTop: getScrollPosition('profile') scrollTop: 0
}); });
this.loadData(); this.loadData();
this.loadUnbindStatus(); this.loadUnbindStatus();
@@ -153,23 +169,28 @@ Page({
// 从分享邀请链接进入 // 从分享邀请链接进入
if (options && options.inviter) { if (options && options.inviter) {
var self = this; var self = this;
setTimeout(function() { self.handleShareInvite(options.inviter); }, 800); setTimeout(function() { self.handleShareInvite(options.inviter, options.invite_code); }, 800);
} }
}, },
onShow: function() { onShow: function() {
resetScrollOnTabShow(this, 'profile');
if (!wx.getStorageSync('coupleId') && !this.data.shareOutgoingCode) api.generateInviteLink().then(res => this.setData({shareOutgoingCode:res.code || ''})).catch(() => {});
const tabBar = this.getTabBar && this.getTabBar();
if (tabBar) tabBar.syncActive();
var uid = wx.getStorageSync('userId'); var uid = wx.getStorageSync('userId');
var cid = wx.getStorageSync('coupleId'); var cid = wx.getStorageSync('coupleId');
var self = this;
var savedTop = getScrollPosition('profile');
this.setData({ scrollTop: savedTop, bindingKnown: false, myLocation: '' });
// 延迟重新设置 scrollTop,解决从子页面 navigateBack 返回后滚动位置丢失的问题
setTimeout(function() {
self.setData({ scrollTop: savedTop });
}, 100);
this.setData({ userId: uid, coupleId: cid }); this.setData({ userId: uid, coupleId: cid });
const app = getApp();
if (app.globalData.openProfileBind) {
app.globalData.openProfileBind = false;
this._openBindOnLoad = true;
this._dataLoads = {};
}
this.loadData(); this.loadData();
this.loadProfileStats();
if (!cid && this.data.showInvitePanel && !this._pollTimer) this.startPollCoupleStatus();
// 如果已绑定情侣,关闭邀请面板 // 如果已绑定情侣,关闭邀请面板
if (cid && this.data.showInvitePanel) { if (cid && this.data.showInvitePanel) {
this.setData({ showInvitePanel: false }); this.setData({ showInvitePanel: false });
@@ -180,6 +201,10 @@ Page({
} }
}, },
onHide: function() {
if (this._pollTimer) { clearInterval(this._pollTimer); this._pollTimer = null; }
},
onUnload: function() { onUnload: function() {
if (this._pollTimer) { if (this._pollTimer) {
clearInterval(this._pollTimer); clearInterval(this._pollTimer);
@@ -188,7 +213,7 @@ Page({
}, },
onPullDownRefresh: async function() { onPullDownRefresh: async function() {
await this.loadData(); await Promise.all([this.loadData(),this.loadProfileStats()]);
wx.stopPullDownRefresh(); wx.stopPullDownRefresh();
}, },
@@ -229,6 +254,7 @@ Page({
hasPartner: false, hasPartner: false,
partnerName: 'TA', partnerName: 'TA',
partnerDisplayId: '', partnerDisplayId: '',
partnerId: 0,
partnerDetail: '', partnerDetail: '',
partnerAvatarUrl: '', partnerAvatarUrl: '',
partnerInitial: '?', partnerInitial: '?',
@@ -322,6 +348,7 @@ Page({
hasPartner: true, hasPartner: true,
partnerName: partner.nickname || partner.username || 'TA', partnerName: partner.nickname || partner.username || 'TA',
partnerDisplayId: partner.user_id || '', partnerDisplayId: partner.user_id || '',
partnerId: Number(partner.id),
partnerDetail: details.join(' · ') || 'TA的信息', partnerDetail: details.join(' · ') || 'TA的信息',
partnerAvatarUrl: getImageUrl(partner.avatar), partnerAvatarUrl: getImageUrl(partner.avatar),
partnerInitial: (partner.nickname || partner.username || '?')[0], partnerInitial: (partner.nickname || partner.username || '?')[0],
@@ -405,6 +432,11 @@ Page({
}).catch(function() {}); }).catch(function() {});
}, },
_syncProfileTabBar: function() {
const tabBar = this.getTabBar && this.getTabBar();
if (tabBar && tabBar.setHidden) tabBar.setHidden(this.data.showEditProfileModal || this.data.showEditPartnerModal);
},
// 编辑资料 // 编辑资料
showEditProfileModal: function() { showEditProfileModal: function() {
var d = this.data; var d = this.data;
@@ -421,6 +453,7 @@ Page({
editAvatarUrl: d.myAvatarUrl || '', editAvatarUrl: d.myAvatarUrl || '',
editNicknameInitial: d.myInitial || '?' editNicknameInitial: d.myInitial || '?'
}); });
this._syncProfileTabBar();
}, },
onEditGenderChange: function(e) { onEditGenderChange: function(e) {
var idx = e.detail.value; var idx = e.detail.value;
@@ -430,6 +463,7 @@ Page({
}, },
hideEditProfileModal: function() { hideEditProfileModal: function() {
this.setData({ showEditProfileModal: false }); this.setData({ showEditProfileModal: false });
this._syncProfileTabBar();
}, },
onEditNicknameInput: function(e) { onEditNicknameInput: function(e) {
this.setData({ editNickname: e.detail.value }); this.setData({ editNickname: e.detail.value });
@@ -559,9 +593,11 @@ Page({
this.setData({ showPartnerDetailModal: false }); this.setData({ showPartnerDetailModal: false });
}, },
// 查看TA资料(只读) // 编辑TA资料
showEditPartnerModal: function() { showEditPartnerModal: function() {
const d = this.data; const d = this.data;
this._editPartnerId = d.partnerId;
this._partnerOriginalStory = d.tempLoveStory;
var rawGender = d.partnerGenderRaw || ''; var rawGender = d.partnerGenderRaw || '';
var genderText = rawGender === 'male' ? '男' : (rawGender === 'female' ? '女' : '未设置'); var genderText = rawGender === 'male' ? '男' : (rawGender === 'female' ? '女' : '未设置');
// 从 partnerDetail 中解析年龄(格式:XX岁) // 从 partnerDetail 中解析年龄(格式:XX岁)
@@ -570,7 +606,7 @@ Page({
this.setData({ this.setData({
showEditPartnerModal: true, showEditPartnerModal: true,
editPartnerNickname: d.partnerName === 'TA' ? '' : d.partnerName, editPartnerNickname: d.partnerName === 'TA' ? '' : d.partnerName,
editPartnerGender: rawGender || 'male', editPartnerGender: rawGender || '',
editPartnerGenderText: genderText, editPartnerGenderText: genderText,
editPartnerAge: partnerAge, editPartnerAge: partnerAge,
editPartnerBirthday: d.partnerBirthday || '', editPartnerBirthday: d.partnerBirthday || '',
@@ -578,10 +614,65 @@ Page({
editPartnerAvatarPath: '', editPartnerAvatarPath: '',
editPartnerNameInitial: d.partnerInitial || '?' editPartnerNameInitial: d.partnerInitial || '?'
}); });
this._syncProfileTabBar();
}, },
hideEditPartnerModal: function() { hideEditPartnerModal: function() {
if (this.data.partnerSaving || this.data.partnerAvatarUploading) return;
this.setData({ showEditPartnerModal: false }); this.setData({ showEditPartnerModal: false });
this._syncProfileTabBar();
}, },
onEditPartnerNicknameInput: function(e) {
if (!this.data.partnerSaving) this.setData({editPartnerNickname:e.detail.value});
},
onEditPartnerGenderChange: function(e) {
if (this.data.partnerSaving) return;
const index = Number(e.detail.value);
this.setData({editPartnerGender:['male','female'][index],editPartnerGenderText:['男','女'][index]});
},
onEditPartnerBirthdayInput: function(e) {
if (this.data.partnerSaving) return;
const birthday = e.detail.value;
const birth = new Date(birthday), today = new Date();
let age = today.getFullYear() - birth.getFullYear();
if (today.getMonth() < birth.getMonth() || (today.getMonth() === birth.getMonth() && today.getDate() < birth.getDate())) age--;
this.setData({editPartnerBirthday:birthday,editPartnerAge:String(Math.max(0,age))});
},
choosePartnerAvatar: function() {
if (this.data.partnerSaving || this.data.partnerAvatarUploading || !this._editPartnerId) return;
const partnerId = this._editPartnerId;
wx.chooseImage({count:1,sizeType:['compressed'],sourceType:['album','camera'],success:async res=>{
if (!this.data.showEditPartnerModal || partnerId !== this._editPartnerId) return;
this.setData({partnerAvatarUploading:true});
try {
const uploaded = await api.uploadPartnerAvatar(res.tempFilePaths[0],partnerId);
this.setData({editPartnerAvatarUrl:getImageUrl(uploaded.path),editPartnerAvatarPath:uploaded.path});
} catch(error) { wx.showToast({title:error.error || '头像上传失败',icon:'none'}); }
finally { this.setData({partnerAvatarUploading:false}); }
}});
},
savePartnerProfile: async function() {
const d = this.data;
if (d.partnerSaving || d.partnerAvatarUploading) return;
if (!d.hasPartner || !this._editPartnerId || this._editPartnerId !== d.partnerId) {
wx.showToast({title:'绑定信息已变化,请重新打开资料',icon:'none'});return;
}
const nickname = d.editPartnerNickname.trim();
if (!nickname) { wx.showToast({title:'请输入TA的昵称',icon:'none'});return; }
this.setData({partnerSaving:true});
try {
const result = await api.updatePartnerProfile({partner_id:this._editPartnerId,nickname,gender:d.editPartnerGender,age:parseInt(d.editPartnerAge) || 0,birthday:d.editPartnerBirthday,avatar:d.editPartnerAvatarPath || undefined});
if (!result || !result.success) throw result || {};
if (d.coupleId && (d.tempLoveDate !== d.loveStartDate || d.tempLoveStory !== this._partnerOriginalStory)) {
await api.updateCoupleInfo({love_start_date:d.tempLoveDate,love_story:d.tempLoveStory || ''});
}
this.setData({partnerSaving:false});
this.hideEditPartnerModal();
await this.loadData();
wx.showToast({title:'TA的资料已保存',icon:'success'});
} catch(error) { wx.showToast({title:error.error || '保存失败,请重试',icon:'none'}); }
finally { this.setData({partnerSaving:false}); }
},
// 加载解绑状态 // 加载解绑状态
loadUnbindStatus: function() { loadUnbindStatus: function() {
var self = this; var self = this;
@@ -610,8 +701,8 @@ Page({
} else { } else {
this.doRespondUnbind(); this.doRespondUnbind();
} }
} else if (status === 'agreed') { } else if (status === 'cooling') {
wx.showToast({ title: '双方已同意解绑,正在处理中', icon: 'none' }); wx.showToast({ title: '正在72小时冷静期', icon: 'none' });
wx.navigateTo({ url: '/pages/unbind/unbind' }); wx.navigateTo({ url: '/pages/unbind/unbind' });
} else { } else {
this.doRequestUnbind(); this.doRequestUnbind();
@@ -622,7 +713,7 @@ Page({
var self = this; var self = this;
wx.showModal({ wx.showModal({
title: '💔 解除绑定', title: '💔 解除绑定',
content: '发起解绑请求后,对方需要在72小时内确认同意。\n\n对方同意后,所有情侣记录将被永久删除(记忆日、日记、相册、纪念日、情话、歌曲、打卡),仅保留双方个人资料。\n\n你也可以在对方确认前撤销请求。', content: '对方同意后进入72小时冷静期,期间任一方可取消。到期自动解绑并永久删除共同数据;个人抽烟记录保留。',
cancelText: '取消', cancelText: '取消',
confirmText: '发起解绑', confirmText: '发起解绑',
confirmColor: '#FF4757', confirmColor: '#FF4757',
@@ -647,7 +738,7 @@ Page({
var self = this; var self = this;
wx.showModal({ wx.showModal({
title: '↩️ 撤销解绑', title: '↩️ 撤销解绑',
content: '确定要撤销解绑请求吗?对方将不会收到任何通知。', content: '取消后关系继续保留。确定取消解绑吗?',
cancelText: '取消', cancelText: '取消',
confirmText: '撤销请求', confirmText: '撤销请求',
confirmColor: '#FF6B9D', confirmColor: '#FF6B9D',
@@ -676,7 +767,7 @@ Page({
if (res.tapIndex === 0) { if (res.tapIndex === 0) {
wx.showModal({ wx.showModal({
title: '⚠️ 确认同意', title: '⚠️ 确认同意',
content: name + ' 发起了解绑请求。\n\n同意后,所有情侣记录将被永久删除(记忆日、日记、相册、歌曲、打卡),仅保留双方个人资料。\n\n此操作不可恢复!', content: name + ' 发起了解绑请求。同意后进入72小时冷静期,期间可取消;到期自动解绑并永久删除共同数据,个人抽烟记录保留。',
cancelText: '取消', cancelText: '取消',
confirmText: '同意解绑', confirmText: '同意解绑',
confirmColor: '#FF4757', confirmColor: '#FF4757',
@@ -685,15 +776,8 @@ Page({
wx.showLoading({ title: '处理中...' }); wx.showLoading({ title: '处理中...' });
api.unbindRespond({ agree: true }).then(function() { api.unbindRespond({ agree: true }).then(function() {
wx.hideLoading(); wx.hideLoading();
wx.showToast({ title: '已同意解绑', icon: 'success' }); wx.showToast({ title: '已进入72小时冷静期', icon: 'none' });
wx.removeStorageSync('coupleId'); self.loadUnbindStatus();
wx.removeStorageSync('partnerId');
wx.removeStorageSync('partnerName');
wx.removeStorageSync('partnerAvatar');
wx.removeStorageSync('petHasPet');
setTimeout(function() {
wx.reLaunch({ url: '/pages/index/index' });
}, 1500);
}).catch(function(err) { }).catch(function(err) {
wx.hideLoading(); wx.hideLoading();
wx.showToast({ title: err.error || '操作失败', icon: 'none' }); wx.showToast({ title: err.error || '操作失败', icon: 'none' });
@@ -747,7 +831,7 @@ Page({
// 跳转前保存当前滚动位置 // 跳转前保存当前滚动位置
_savePosBeforeLeave: function() { _savePosBeforeLeave: function() {
saveScrollPosition('profile', this.data.scrollTop); saveScrollPosition('profile', this._scrollTop == null ? this.data.scrollTop : this._scrollTop);
}, },
// ===== 邀请绑定 ===== // ===== 邀请绑定 =====
@@ -796,9 +880,12 @@ Page({
}, },
sendInvite: async function() { sendInvite: async function() {
const { inviteTargetId, inviteMessage } = this.data; const inviteTargetId = String(this.data.inviteTargetId || '').trim();
const { inviteMessage } = this.data;
if (!inviteTargetId) { wx.showToast({ title: '请输入对方ID', icon: 'none' }); return; } if (!inviteTargetId) { wx.showToast({ title: '请输入对方ID', icon: 'none' }); return; }
if (inviteTargetId.length < 6) { wx.showToast({ title: 'ID为6位数字', icon: 'none' }); return; } if (!/^[0-9]{6}$/.test(inviteTargetId)) { wx.showToast({ title: 'ID为6位数字', icon: 'none' }); return; }
// 邀请成功且对方接受时,需要通知邀请人,提前申请一次性订阅授权
setTimeout(function() { requestSubscribe('notify').catch(function() {}); }, 1500);
try { try {
wx.showLoading({ title: '发送中...' }); wx.showLoading({ title: '发送中...' });
const res = await api.invitePartner({ target_user_id: inviteTargetId, message: inviteMessage }); const res = await api.invitePartner({ target_user_id: inviteTargetId, message: inviteMessage });
@@ -880,6 +967,7 @@ Page({
// ===== 分享绑定 ===== // ===== 分享绑定 =====
onShareAppMessage: function() { onShareAppMessage: function() {
if (!this.data.shareOutgoingCode) return {title:'邀请你绑定情侣', path:'/pages/profile/profile'};
var displayId = wx.getStorageSync('displayId') || this.data.myDisplayId; var displayId = wx.getStorageSync('displayId') || this.data.myDisplayId;
var nick = wx.getStorageSync('nickname') || '我'; var nick = wx.getStorageSync('nickname') || '我';
if (!displayId) { if (!displayId) {
@@ -888,22 +976,22 @@ Page({
} }
return { return {
title: nick + ' 邀请你绑定情侣 💕', title: nick + ' 邀请你绑定情侣 💕',
path: '/pages/profile/profile?inviter=' + displayId, path: '/pages/profile/profile?inviter=' + displayId + '&invite_code=' + encodeURIComponent(this.data.shareOutgoingCode || ''),
imageUrl: '/images/share.png' imageUrl: '/images/share.png'
}; };
}, },
// 从分享链接进入时处理邀请绑定 // 从分享链接进入时处理邀请绑定
handleShareInvite: function(inviterId) { handleShareInvite: function(inviterId, code) {
if (!inviterId) return; if (!inviterId) return;
this.setData({ shareInviterId: inviterId, showShareBindModal: true, shareDisagree: false }); this.setData({ shareInviterId: inviterId, shareInviteCode: code || '', showShareBindModal: true, shareDisagree: false });
}, },
acceptShareBind: async function() { acceptShareBind: async function() {
var self = this; var self = this;
try { try {
wx.showLoading({ title: '处理中...' }); wx.showLoading({ title: '处理中...' });
const res = await api.acceptShareInvite({ inviter_id: this.data.shareInviterId }); const res = await api.acceptShareInvite({ inviter_id: this.data.shareInviterId, invite_code: this.data.shareInviteCode });
wx.hideLoading(); wx.hideLoading();
if (res.couple_id) { if (res.couple_id) {
wx.setStorageSync('coupleId', res.couple_id); wx.setStorageSync('coupleId', res.couple_id);
@@ -925,8 +1013,36 @@ Page({
this.setData({ showShareBindModal: false }); this.setData({ showShareBindModal: false });
}, },
loadProfileStats: async function() {
this.setData({statsLoading:true,statsError:false});
const keys=['timeline','album','diary','songs','checkin','pet'];
const results=await Promise.allSettled([
api.timelineList(),api.albumList(),api.diaryList(),api.songsList(),api.checkinList(),
(this.data.coupleId || wx.getStorageSync('coupleId')) ? api.petInfo() : Promise.resolve({has_pet:false})
]);
const counts={};let failed=false;
results.forEach((result,index)=>{
const key=keys[index];
if(result.status !== 'fulfilled') { counts[key]='—';failed=true;return; }
const value=result.value;
if(key==='album') counts[key]=(Array.isArray(value)?value:[]).reduce((sum,album)=>sum+(Number(album.photo_count)||0),0);
else if(key==='diary') counts[key]=Number(value && value.total)||0;
else if(key==='checkin') counts[key]=value && value.checkin_log_total !== undefined ? Number(value.checkin_log_total)||0 : (Array.isArray(value)?value.length:(value && value.items || []).length);
else if(key==='pet') counts[key]=value && value.has_pet && value.pet ? 'Lv.'+(Number(value.pet.level)||0) : '未领养';
else counts[key]=Array.isArray(value)?value.length:0;
});
this.setData({statisticItems:this.data.statisticItems.map(item=>({...item,count:counts[item.key]===undefined?item.count:counts[item.key]})),statsLoading:false,statsError:failed});
},
openStatistic: function(e) {
const routes={timeline:'timeline',album:'albums',diary:'diary',songs:'lovewords',checkin:'punch',pet:'pet',period:'period',settings:'settings'};
const route=routes[e.currentTarget.dataset.key];
if(!route)return;
this._savePosBeforeLeave();
wx.navigateTo({url:'/pages/'+route+'/'+route});
},
// 页面跳转 // 页面跳转
manageRelationship: function() { wx.navigateTo({ url: '/pages/unbind/unbind' }); }, manageRelationship: function() { wx.navigateTo({ url: '/pages/unbind/unbind' }); },
goToSettings: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/settings/settings' }); }, goToSettings: function() { this._savePosBeforeLeave(); wx.navigateTo({ url: '/pages/settings/settings' }); },
goHome: function() { this._savePosBeforeLeave(); wx.redirectTo({ url: '/pages/index/index' }); } goHome: function() { this._savePosBeforeLeave(); wx.switchTab({ url: '/pages/index/index' }); }
}); }, {"loadData": ["coupleInfo"], "loadProfileStats": ["timelineList","albumList","diaryList","songsList","checkinList","petInfo"]}));
-1
View File
@@ -1,6 +1,5 @@
{ {
"usingComponents": { "usingComponents": {
"app-tabbar": "/components/app-tabbar/app-tabbar",
"page-state": "/components/page-state/page-state" "page-state": "/components/page-state/page-state"
}, },
"navigationStyle": "custom", "navigationStyle": "custom",
+29 -20
View File
@@ -1,47 +1,56 @@
<view class="app-shell" style="{{layoutStyle}}"> <view class="app-shell j-handwritten-page" style="{{layoutStyle}}">
<launch-transition /> <launch-transition />
<!-- pages/profile/profile.wxml --> <!-- pages/profile/profile.wxml -->
<view wx:if="{{!showEditProfileModal && !showEditPartnerModal}}" class="j-fixed-header"><view class="j-fixed-heading" style="height:{{barHeight}}px;padding-right:{{navSide}}px;"><text class="j-eyebrow">MY CORNER</text><text class="j-page-title">我的小角落</text></view></view> <view wx:if="{{!showEditProfileModal && !showEditPartnerModal}}" class="j-fixed-header"><view class="j-fixed-heading" style="height:{{barHeight}}px;padding-right:{{navSide}}px;"><text class="j-eyebrow">MY CORNER</text><text class="j-page-title">我的小角落</text></view></view>
<view wx:if="{{!showEditProfileModal && !showEditPartnerModal}}" class="page-root" bindtouchstart="onTouchStart" bindtouchend="onTouchEnd"> <view wx:if="{{!showEditProfileModal && !showEditPartnerModal}}" class="page-root" bindtouchstart="onTouchStart" bindtouchend="onTouchEnd">
<scroll-view class="page-bg j-main-scroll" scroll-y show-scrollbar="{{false}}" bounces="{{true}}" bindscroll="onScroll" scroll-top="{{scrollTop}}"> <scroll-view class="page-bg j-main-scroll" scroll-y show-scrollbar="{{false}}" bounces="{{true}}" bindscroll="onScroll" scroll-top="{{scrollTop}}">
<view class="j-body"> <view class="j-body">
<view class="j-profile-summary" bindtap="showEditProfileModal"><view class="j-profile-info"><text class="j-profile-name">{{myNickname}}</text><text class="j-caption">ID {{myDisplayId}}</text><text class="j-caption">{{myGender}} {{myAge}} {{myBirthday}}</text><text class="j-link">编辑资料 →</text></view><view class="j-avatar"><image wx:if="{{myAvatarUrl}}" src="{{myAvatarUrl}}" mode="aspectFill" /><text wx:else>{{myInitial}}</text></view></view> <view class="j-profile-summary" bindtap="showEditProfileModal"><view class="j-profile-info"><text class="j-profile-name">{{myNickname}}</text><text class="j-caption">ID {{myDisplayId}}</text><text class="j-caption">{{myGender}} {{myAge}} {{myBirthday}}</text></view><view class="j-avatar"><image wx:if="{{myAvatarUrl}}" src="{{myAvatarUrl}}" mode="aspectFill" /><text wx:else>{{myInitial}}</text></view></view>
<view class="j-section-head"><text>我们的关系</text></view> <view class="j-section-head"><text>我们的关系</text></view>
<view wx:if="{{bindingKnown && hasPartner}}" class="j-relation-card"><view class="j-row" bindtap="showEditPartnerModal"><view><text class="j-small-title">{{partnerName}}</text><text class="j-caption" wx:if="{{partnerDisplayId}}">ID {{partnerDisplayId}}</text><text class="j-caption">{{partnerDetail}}</text><text class="j-link">查看TA的资料 →</text></view><view class="j-avatar"><image wx:if="{{partnerAvatarUrl}}" src="{{partnerAvatarUrl}}" mode="aspectFill" /><text wx:else>{{partnerInitial}}</text></view></view><view class="j-profile-setting" bindtap="manageRelationship"><image src="/images/heart.svg" /><view><text class="j-small-title">关系管理</text><text class="j-caption">绑定信息与解绑</text></view><text class="j-link">›</text></view></view> <view wx:if="{{bindingKnown && hasPartner}}" class="j-relation-card" bindtap="showEditPartnerModal"><view class="j-row"><view><text class="j-small-title">{{partnerName}}</text><text class="j-caption" wx:if="{{partnerDisplayId}}">ID {{partnerDisplayId}}</text><text class="j-caption">{{partnerDetail}}</text></view><view class="j-avatar"><image wx:if="{{partnerAvatarUrl}}" src="{{partnerAvatarUrl}}" mode="aspectFill" /><text wx:else>{{partnerInitial}}</text></view></view></view>
<view wx:elif="{{bindingKnown}}" class="j-invite" bindtap="showInvitePanel"><view><text class="j-small-title">另一半还没有加入</text><text class="j-caption">邀请TA,共写这本生活手账</text></view><text class="j-outline-label">绑定情侣</text></view> <view wx:elif="{{bindingKnown}}" class="j-invite" bindtap="showInvitePanel"><view><text class="j-small-title">另一半还没有加入</text><text class="j-caption">邀请TA,共写这本生活手账</text></view><text class="j-outline-label">绑定情侣</text></view>
<page-state wx:elif="{{bindingError}}" kind="error" title="绑定状态加载失败" action="重新加载" bind:action="loadData" /><page-state wx:else kind="loading" title="正在获取绑定状态" /> <page-state wx:elif="{{bindingError}}" kind="error" title="绑定状态加载失败" action="重新加载" bind:action="loadData" /><page-state wx:else kind="loading" title="正在获取绑定状态" />
<view class="j-profile-setting" bindtap="goToSettings"><image src="/images/settings.svg" /><view><text class="j-small-title">外观与通知</text><text class="j-caption">背景、通知与账号设置</text></view><text class="j-link">›</text></view> <view class="profile-statistics">
<view class="profile-stat-grid">
<view class="profile-stat-item" wx:for="{{statisticItems}}" wx:key="key" data-key="{{item.key}}" bindtap="openStatistic" hover-class="profile-stat-pressed">
<view class="profile-stat-icon {{item.color}}"><image src="/images/{{item.icon}}.svg" mode="aspectFit" /></view>
<view class="profile-stat-label"><text>{{item.label}}</text><text wx:if="{{item.count !== ''}}" class="profile-stat-count">{{item.count}}</text></view>
</view>
</view>
<view wx:if="{{statsError}}" class="profile-stats-retry" bindtap="loadProfileStats">部分统计未加载,点击重试</view>
</view>
</view> </view>
</scroll-view> </scroll-view>
</view> </view>
<app-tabbar />
<view wx:if="{{showEditProfileModal}}" class="edit-root" bindtouchstart="onTouchStart" bindtouchend="onTouchEnd"> <view wx:if="{{showEditProfileModal}}" class="edit-root" bindtouchstart="onTouchStart" bindtouchend="onTouchEnd">
<view class="edit-nav"><view class="back-btn" bindtap="hideEditProfileModal"><image class="back-icon" src="/images/back.svg" mode="aspectFit" /></view></view> <view class="edit-nav"><view class="back-btn" bindtap="hideEditProfileModal"><image class="back-icon" src="/images/back.svg" mode="aspectFit" /></view></view>
<view class="edit-body" bindtouchstart="onTouchStart" bindtouchend="onTouchEnd"> <scroll-view class="edit-body" scroll-y show-scrollbar="{{false}}" bindtouchstart="onTouchStart" bindtouchend="onTouchEnd">
<view class="edit-avatar-wrap" bindtap="chooseAvatar"><image wx:if="{{editAvatarUrl}}" src="{{editAvatarUrl}}" mode="aspectFill" class="edit-avatar-img"></image><text wx:else class="edit-avatar-placeholder">{{editNicknameInitial || '?'}}</text><text class="edit-avatar-hint">点击更换头像</text></view> <view class="edit-avatar-wrap" bindtap="chooseAvatar"><image wx:if="{{editAvatarUrl}}" src="{{editAvatarUrl}}" mode="aspectFill" class="edit-avatar-img"></image><text wx:else class="edit-avatar-placeholder">{{editNicknameInitial || '?'}}</text><text class="edit-avatar-hint">点击更换头像</text></view>
<text class="edit-id">我的ID: {{myDisplayId}}</text> <text class="edit-id">我的ID: {{myDisplayId}}</text>
<view class="input-group"><label>昵称</label><input class="input" placeholder="你的昵称" value="{{editNickname}}" bindinput="onEditNicknameInput" /></view> <view class="input-group"><label>昵称</label><input placeholder-class="handwritten-placeholder" class="input" placeholder="你的昵称" value="{{editNickname}}" bindinput="onEditNicknameInput" /></view>
<view class="input-group"><label>性别</label><picker mode="selector" range="{{genderOptions}}" bindchange="onEditGenderChange"><view class="input">{{editGenderText || '请选择'}}</view></picker></view> <view class="input-group"><label>性别</label><picker mode="selector" range="{{genderOptions}}" bindchange="onEditGenderChange"><view class="input">{{editGenderText || '请选择'}}</view></picker></view>
<view class="input-group"><label>生日</label><picker mode="date" value="{{editBirthday}}" bindchange="onEditBirthdayInput"><view class="input">{{editBirthday || '请选择日期'}}</view></picker><text style="font-size:24rpx;color:#B74768;margin-top:8rpx;display:block;" wx:if="{{editAge}}">自动计算:{{editAge}}岁</text></view> <view class="input-group"><label>生日</label><picker mode="date" value="{{editBirthday}}" bindchange="onEditBirthdayInput"><view class="input">{{editBirthday || '请选择日期'}}</view></picker><text style="font-size:24rpx;color:#B74768;margin-top:8rpx;display:block;" wx:if="{{editAge}}">自动计算:{{editAge}}岁</text></view>
<view class="input-group" wx:if="{{hasPartner}}"><label>恋爱纪念日</label><picker mode="date" value="{{tempLoveDate}}" bindchange="onLoveDateInput"><view class="input">{{tempLoveDate || '设置你们在一起的日期'}}</view></picker></view> <view class="input-group" wx:if="{{hasPartner}}"><label>恋爱纪念日</label><picker mode="date" value="{{tempLoveDate}}" bindchange="onLoveDateInput"><view class="input">{{tempLoveDate || '设置你们在一起的日期'}}</view></picker></view>
<view class="input-group" wx:if="{{hasPartner}}"><label>恋爱故事</label><textarea class="input" style="min-height:120rpx;" placeholder="记录你们的故事..." value="{{tempLoveStory}}" bindinput="onLoveStoryInput"></textarea></view> <view class="input-group" wx:if="{{hasPartner}}"><label>恋爱故事</label><textarea placeholder-class="handwritten-placeholder" class="input" style="min-height:120rpx;" placeholder="记录你们的故事..." value="{{tempLoveStory}}" bindinput="onLoveStoryInput"></textarea></view>
</view> </scroll-view>
<view class="edit-footer"><button class="btn btn-primary" bindtap="saveProfile" hover-class="none">保存</button></view> <view class="edit-footer"><button class="btn btn-primary" bindtap="saveProfile" hover-class="none">保存</button></view>
</view> </view>
<view wx:if="{{showEditPartnerModal}}" class="edit-root partner-view" bindtouchstart="onTouchStart" bindtouchend="onTouchEnd"> <view wx:if="{{showEditPartnerModal}}" class="edit-root partner-edit" bindtouchstart="onTouchStart" bindtouchend="onTouchEnd">
<view class="edit-nav"><view class="back-btn" bindtap="hideEditPartnerModal"><image class="back-icon" src="/images/back.svg" mode="aspectFit" /></view><text class="partner-nav-title">TA的资料</text></view> <view class="edit-nav"><view class="back-btn" bindtap="hideEditPartnerModal"><image class="back-icon" src="/images/back.svg" mode="aspectFit" /></view><text class="partner-nav-title">编辑TA的资料</text></view>
<view class="edit-body" bindtouchstart="onTouchStart" bindtouchend="onTouchEnd"> <scroll-view class="edit-body" scroll-y show-scrollbar="{{false}}">
<view class="edit-avatar-wrap"><image wx:if="{{editPartnerAvatarUrl}}" src="{{editPartnerAvatarUrl}}" mode="aspectFill" class="edit-avatar-img"></image><text wx:else class="edit-avatar-placeholder">{{editPartnerNameInitial || '?'}}</text></view> <view class="edit-avatar-wrap" bindtap="choosePartnerAvatar"><image wx:if="{{editPartnerAvatarUrl}}" src="{{editPartnerAvatarUrl}}" mode="aspectFill" class="edit-avatar-img" /><text wx:else class="edit-avatar-placeholder">{{editPartnerNameInitial || '?'}}</text><text class="edit-avatar-hint">{{partnerAvatarUploading ? '正在上传头像…' : '点击更换头像'}}</text></view>
<text class="edit-id" wx:if="{{partnerDisplayId}}">TA的ID: {{partnerDisplayId}}</text> <text class="edit-id" wx:if="{{partnerDisplayId}}">TA的ID: {{partnerDisplayId}}</text>
<view class="input-group readonly"><label>昵称</label><view class="input readonly-text">{{editPartnerNickname || '未设置'}}</view></view> <view class="input-group"><label>昵称</label><input placeholder-class="handwritten-placeholder" class="input" value="{{editPartnerNickname}}" placeholder="TA的昵称" bindinput="onEditPartnerNicknameInput" disabled="{{partnerSaving}}" /></view>
<view class="input-group readonly"><label>性别</label><view class="input readonly-text">{{editPartnerGenderText || '-'}}</view></view> <view class="input-group"><label>性别</label><picker mode="selector" range="{{genderOptions}}" bindchange="onEditPartnerGenderChange" disabled="{{partnerSaving}}"><view class="input">{{editPartnerGenderText || '请选择'}}</view></picker></view>
<view class="input-group readonly"><label>生日</label><view class="input readonly-text">{{editPartnerBirthday || '-'}}</view></view> <view class="input-group"><label>生日</label><picker mode="date" value="{{editPartnerBirthday}}" bindchange="onEditPartnerBirthdayInput" disabled="{{partnerSaving}}"><view class="input">{{editPartnerBirthday || '请选择日期'}}</view></picker><text class="edit-avatar-hint" wx:if="{{editPartnerAge}}">自动计算:{{editPartnerAge}}岁</text></view>
<view class="input-group readonly"><label>恋爱纪念日</label><view class="input readonly-text">{{tempLoveDate || '未设置'}}</view></view> <view class="input-group"><label>恋爱纪念日</label><picker mode="date" value="{{tempLoveDate}}" bindchange="onLoveDateInput" disabled="{{partnerSaving}}"><view class="input">{{tempLoveDate || '设置你们在一起的日期'}}</view></picker></view>
<view class="input-group readonly"><label>恋爱故事</label><view class="input readonly-text" style="min-height:60rpx;">{{tempLoveStory || '未设置'}}</view></view> <view class="input-group"><label>恋爱故事</label><textarea placeholder-class="handwritten-placeholder" class="input" style="min-height:120rpx;" value="{{tempLoveStory}}" placeholder="记录你们的故事…" bindinput="onLoveStoryInput" disabled="{{partnerSaving}}" /></view>
</view> </scroll-view>
<view class="edit-footer"><button class="btn btn-primary" bindtap="savePartnerProfile" loading="{{partnerSaving}}" disabled="{{partnerSaving || partnerAvatarUploading}}" hover-class="none">保存</button></view>
</view> </view>
<!-- ===== 邀请绑定面板 ===== --> <!-- ===== 邀请绑定面板 ===== -->
@@ -62,7 +71,7 @@
<view class="input-group"> <view class="input-group">
<label>TA的ID</label> <label>TA的ID</label>
<input class="input" type="tel" placeholder="输入对方的6位ID" value="{{inviteTargetId}}" bindinput="onInviteTargetIdInput" maxlength="6" /> <input placeholder-class="handwritten-placeholder" class="input" type="tel" placeholder="输入对方的6位ID" value="{{inviteTargetId}}" bindinput="onInviteTargetIdInput" maxlength="6" />
</view> </view>
<button class="btn btn-primary" bindtap="sendInvite" hover-class="none"> <button class="btn btn-primary" bindtap="sendInvite" hover-class="none">
<text>💕</text> 发送邀请 <text>💕</text> 发送邀请
+64 -5
View File
@@ -1,6 +1,6 @@
/* pages/profile/profile.wxss */ /* pages/profile/profile.wxss */
@import '../../styles/common.wxss'; @import '../../styles/common.wxss';
page { background: linear-gradient(180deg, #F4E2E3 0%, #FAF5EE 55%, #FAF5EE 100%); height: 100vh; overflow: hidden; } page { background: linear-gradient(180deg, #FDE4EC 0%, #FFF0F5 48%, #FFF7FA 100%); height: 100vh; overflow: hidden; }
.container { padding-top: 16rpx; padding-bottom: 300rpx; } .container { padding-top: 16rpx; padding-bottom: 300rpx; }
.page-root { height: 100vh; overflow: hidden; } .page-root { height: 100vh; overflow: hidden; }
scroll-view.page-bg { height: 100vh; overflow-y: scroll; -webkit-overflow-scrolling: touch; } scroll-view.page-bg { height: 100vh; overflow-y: scroll; -webkit-overflow-scrolling: touch; }
@@ -301,8 +301,67 @@ scroll-view.page-bg { height: 100vh; overflow-y: scroll; -webkit-overflow-scroll
@import '../../styles/journal.wxss'; @import '../../styles/journal.wxss';
/* TA 的资料仅供查看,不预留编辑操作区。 */ /* TA 资料编辑页的标题与滚动区域。 */
.partner-view .edit-body { min-height: 0; padding-bottom: calc(40rpx + env(safe-area-inset-bottom)); } .partner-edit .edit-body { min-height: 0; padding-bottom: calc(40rpx + env(safe-area-inset-bottom)); }
.partner-view .edit-nav { position: relative; }
.partner-nav-title { position: absolute; left: 120rpx; right: 120rpx; text-align: center; font-size: 32rpx; font-weight: 600; color: #342F2B; pointer-events: none; } .partner-nav-title { position: absolute; left: 120rpx; right: 120rpx; text-align: center; font-size: 32rpx; font-weight: 600; color: #342F2B; pointer-events: none; }
.partner-view .readonly-text { white-space: pre-wrap; overflow-wrap: anywhere; } .partner-edit .readonly-text { white-space: pre-wrap; overflow-wrap: anywhere; }
/* 编辑内容独立滚动,底部保存区始终保留空间。 */
.edit-root > .edit-body { flex:1; height:0; min-height:0; box-sizing:border-box; padding-bottom:24rpx; }
.edit-root > .edit-footer { flex-shrink:0; box-sizing:border-box; }
.edit-footer > .btn { width:100%; margin:0; }
/* 个人页八宫格统计入口。 */
.profile-statistics { margin-top:32rpx; padding:28rpx 16rpx; background:#FFFDF8; border:1rpx solid #F1DCE4; border-radius:32rpx; }
.profile-stat-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:28rpx 6rpx; }
.profile-stat-item { min-width:0; display:flex; flex-direction:column; align-items:center; padding:8rpx 0; }
.profile-stat-icon { width:94rpx; height:94rpx; border-radius:30rpx; display:flex; align-items:center; justify-content:center; }
.profile-stat-icon image { width:58rpx; height:58rpx; }
.profile-stat-icon.pink { background:#FCE5ED; }.profile-stat-icon.yellow { background:#FFF3DC; }.profile-stat-icon.mint { background:#E5F5ED; }.profile-stat-icon.blue { background:#EAF0FD; }.profile-stat-icon.purple { background:#F1EAFB; }.profile-stat-icon.gray { background:#F0EFF2; }
.profile-stat-label { display:flex; flex-wrap:wrap; justify-content:center; align-items:baseline; gap:6rpx; margin-top:14rpx; font-family:'Kaiti SC','STKaiti','KaiTi',cursive; font-size:25rpx; line-height:1.5; color:#64424D; }
.profile-stat-count { font-family:-apple-system,BlinkMacSystemFont,sans-serif; font-size:19rpx; color:#A39386; }
.profile-stat-pressed { opacity:.65; }
.profile-stats-retry { text-align:center; color:#B74768; font-size:22rpx; padding:24rpx 0 0; }
/* 本人与对方资料卡共用完整样式,整卡点击编辑。 */
.j-body > .j-profile-summary,
.j-body > .j-relation-card {
height:216rpx;
box-sizing:border-box;
display:flex;
align-items:center;
padding:24rpx 30rpx;
margin:20rpx 0 24rpx;
background:#FFFDF8;
border:1rpx solid #E9DDD0;
border-radius:12rpx 40rpx 12rpx 40rpx;
box-shadow:none;
}
.j-relation-card > .j-row { width:100%; gap:30rpx; }
.j-profile-summary .j-profile-name,
.j-relation-card .j-small-title {
display:block;
font-family:'Kaiti SC','STKaiti','KaiTi',cursive;
font-size:30rpx;
font-weight:400;
font-style:normal;
line-height:1.6;
color:#64424D;
overflow:hidden;
white-space:nowrap;
text-overflow:ellipsis;
}
.j-profile-summary .j-avatar,
.j-relation-card .j-avatar {
width:96rpx;
height:96rpx;
flex-shrink:0;
box-sizing:border-box;
border:6rpx solid #FFFDF8;
font-size:32rpx;
}
@import '../../styles/button-theme.wxss';
@import '../../styles/typography.wxss';

Some files were not shown because too many files have changed in this diff Show More