同步小程序与管理端更新,完善抽烟记录和解绑冷静期
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 一行查询结果,键为列名(或别名)。
|
||||||
|
|||||||
@@ -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"}},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,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 || '';
|
||||||
|
|||||||
@@ -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": "我的"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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' });
|
||||||
} }
|
} }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,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';
|
||||||
@@ -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; } });
|
||||||
|
}
|
||||||
|
} });
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"component":true}
|
||||||
@@ -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>
|
||||||
@@ -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';
|
||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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"]}));
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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"]}));
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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"]}));
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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"]}));
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 || '',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
<!-- 图片(可删除) -->
|
<!-- 图片(可删除) -->
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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; } }
|
||||||
|
|||||||
@@ -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' }); }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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"]}));
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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"]}));
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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"]}));
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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> 发送邀请
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||