diff --git a/package.json b/package.json
index 5ad26bd..af91dba 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,6 @@
"build:mp-weixin": "node ./node_modules/@dcloudio/vite-plugin-uni/bin/uni.js build -p mp-weixin --mode production",
"dev:h5": "node ./node_modules/@dcloudio/vite-plugin-uni/bin/uni.js",
"build:h5": "node ./node_modules/@dcloudio/vite-plugin-uni/bin/uni.js build",
- "build:mock": "node ./scripts/build-mock.mjs",
"build:icons": "node ./scripts/build-icons.mjs"
},
"dependencies": {
diff --git a/scripts/build-mock.mjs b/scripts/build-mock.mjs
deleted file mode 100644
index 7affa20..0000000
--- a/scripts/build-mock.mjs
+++ /dev/null
@@ -1,199 +0,0 @@
-/**
- * 一次性数据构建脚本:把 DuohuaDiscovery 的 miniprogram_data.json
- * 拆成小程序运行期使用的 mock 数据(字段与未来接口同构)。
- *
- * 运行:node scripts/build-mock.mjs
- */
-import fs from "node:fs";
-import path from "node:path";
-
-const ROOT = path.resolve(import.meta.dirname, "..");
-const SRC = "/Users/gleen/OneFeel/YGChatCS/src/pages/DuohuaDiscovery/data/miniprogram_data.json";
-const OUT = path.join(ROOT, "src/mock");
-
-const data = JSON.parse(fs.readFileSync(SRC, "utf8"));
-
-const SECTION_DEFINITIONS = [
- {
- key: "tourism",
- title: "龙里票根经济",
- subtitle: "领取龙里旅游优惠",
- coverImage:
- "https://one-feel-bucket.oss-cn-guangzhou.aliyuncs.com/longli/miniprogram/attractions/attraction-001/01.jpg",
- collections: ["attractions", "attraction_subprojects"],
- supportAssetIds: ["tourism-overview-map", "tourism-guide-qr"],
- },
- {
- key: "lodging",
- title: "旅居服务",
- subtitle: "酒店 · 民宿别院 · 旅居公寓 · 露营基地",
- collections: ["accommodations"],
- supportAssetIds: ["lodging-location-schematic", "lodging-guide-qr"],
- },
- {
- key: "food",
- title: "全域美食",
- subtitle: "特色菜肴 · 地方小吃 · 甜品饮品",
- collections: ["foods"],
- supportAssetIds: ["food-guide-qr"],
- },
- {
- key: "specialties",
- title: "龙里特产",
- subtitle: "刺梨制品 · 地方食品 · 农产品",
- collections: ["specialties"],
- supportAssetIds: [],
- },
-];
-
-const COLLECTIONS = [
- { key: "attractions", label: "景点" },
- { key: "attraction_subprojects", label: "游乐演艺" },
- { key: "accommodations", label: "旅居" },
- { key: "foods", label: "美食" },
- { key: "specialties", label: "特产" },
-];
-
-/** 一期 mock:源数据无营业时间字段,统一占位,待运营方按商户补充 */
-const MOCK_BUSINESS_HOURS = "09:00–22:00";
-
-const DROP_FIELDS = [
- "issues",
- "source",
- "source_no",
- "images",
- "main_image_object_key",
- "gallery_object_keys",
- "local_relative_path",
- "oss_object_key",
- "sha256",
- "source_file",
- "source_page",
- "pdf_image_name",
- "pdf_x0",
- "pdf_top",
- "pdf_x1",
- "pdf_bottom",
- "conversion_note",
- "color_mode",
-];
-
-const trim = (obj) => {
- const out = {};
- for (const [k, v] of Object.entries(obj)) {
- if (DROP_FIELDS.includes(k)) continue;
- if (v === null || v === undefined || v === "") continue;
- out[k] = v;
- }
- return out;
-};
-
-/** 电话归一:源数据里分隔符混用「—」「–」「-」,统一成半角连字符;另给纯数字供 makePhoneCall */
-const normalizeContact = (item) => {
- const raw = item?.contact?.raw || "";
- if (!raw) return null;
- const display = raw.replace(/[—–-~~\s]/g, "-").replace(/-+/g, "-").replace(/-$/, "");
- const dial = display.replace(/\D/g, "");
- return { raw, display, dial };
-};
-
-/** 地址归一:源数据 address 是对象(含经纬度为 null),页面只用一个字符串;无地址返回空串(页面整行隐藏) */
-const normalizeAddress = (item) => {
- const addr = item?.address;
- if (!addr) return "";
- if (typeof addr === "string") return addr;
- if (addr.raw || addr.normalized) return addr.raw || addr.normalized;
- if (addr.township_or_street) {
- return [addr.province, addr.prefecture, addr.county, addr.township_or_street].filter(Boolean).join("");
- }
- return "";
-};
-
-const items = {};
-const counts = {};
-
-for (const { key } of COLLECTIONS) {
- const list = Array.isArray(data[key]) ? data[key] : [];
- let n = 0;
- for (const raw of list) {
- // 硬约束:过滤 enabled === false
- if (raw.enabled === false) continue;
- const item = trim(raw);
- delete item.enabled;
- delete item.address;
- item.collection = key;
- item.business_hours = item.business_hours || MOCK_BUSINESS_HOURS;
- item.contact = normalizeContact(raw);
- item.address_text = normalizeAddress(raw);
- item.badge = item.subtype || item.category || item.type || "";
- items[raw.id] = item;
- n += 1;
- }
- counts[key] = n;
-}
-
-/** 分组目录:过滤 enabled=false / 空分组,按 type_order -> sort_within_type 排序 */
-const catalog = {};
-for (const { key } of COLLECTIONS) {
- const groups = Array.isArray(data?.catalog_by_type?.[key]) ? data.catalog_by_type[key] : [];
- catalog[key] = groups
- .map((group) => {
- const ids = (group.items || [])
- .filter((it) => items[it.id])
- .sort((a, b) => (a.sort_within_type ?? 0) - (b.sort_within_type ?? 0))
- .map((it) => it.id);
- return {
- collection: key,
- type_code: group.type_code,
- type: group.type,
- type_order: group.type_order ?? 0,
- item_count: ids.length,
- item_ids: ids,
- };
- })
- .filter((group) => group.item_count > 0)
- .sort((a, b) => a.type_order - b.type_order);
-}
-
-const assets = (data.assets || []).map((a) => ({
- asset_id: a.asset_id,
- entity_type: a.entity_type,
- entity_id: a.entity_id,
- entity_name: a.entity_name,
- role: a.role,
- sort_order: a.sort_order ?? 0,
- oss_url: a.oss_url,
- width_px: a.width_px,
- height_px: a.height_px,
- content_type: a.content_type,
-}));
-
-const sections = SECTION_DEFINITIONS.map((def) => {
- const groups = def.collections.flatMap((c) =>
- catalog[c].map((g) => ({ ...g, groupId: `${c}-${g.type_code}` }))
- );
- const ids = groups.flatMap((g) => g.item_ids);
- const first = items[ids[0]];
- return {
- ...def,
- group_count: groups.length,
- item_count: ids.length,
- group_ids: groups.map((g) => g.groupId),
- };
-});
-
-const write = (name, payload) => {
- const file = path.join(OUT, name);
- fs.writeFileSync(file, JSON.stringify(payload, null, 0) + "\n", "utf8");
- const kb = (fs.statSync(file).size / 1024).toFixed(1);
- console.log(`${name.padEnd(16)} ${kb} KB`);
-};
-
-fs.mkdirSync(OUT, { recursive: true });
-write("sections.json", sections);
-write("catalog.json", catalog);
-write("items.json", items);
-write("assets.json", assets);
-
-console.log("\n集合条数(已过滤 enabled=false):", counts);
-console.log("items 总数:", Object.keys(items).length);
diff --git a/src/App.vue b/src/App.vue
index aafaba6..cc30817 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1,14 +1,12 @@
diff --git a/src/api/benefit.js b/src/api/benefit.js
index 945e37c..3079a69 100644
--- a/src/api/benefit.js
+++ b/src/api/benefit.js
@@ -1,19 +1,82 @@
/**
* 权益数据访问层(唯一数据入口)
* ---------------------------------------------------------------------------
- * 核心口径(v0.8 / v0.11):
- * - 权益不绑定具体商品、可多次使用;使用只累加 use_count + last_used_at,不失效
- * - 状态机 4 态:available / claimed / expired / sold_out
- * - 一期领取与使用记录写本地 storage;二期换成接口(src/request/api/benefit.js)
+ * 页面与 store 只调用本文件,不直接 import mock。
+ * USE_MOCK_CONTENT = true → 读 src/mock/v1/*(已是 /v1 接口响应形态,无需再适配);
+ * false → 走 src/request/api/benefit.js 的真实 /v1 通道。
+ *
+ * 接口口径:
+ * - 公开权益 `PublicBenefitView`:status 恒为 published,用户领取态由
+ * `already_claimed` / `can_claim` / `claim_unavailable_reason` / `user_benefit_id` 表达。
+ * - 已领取实例 `UserBenefitView`:status = available | pending | expired | exhausted | voided,
+ * `can_use` 直接驱动使用按钮;列表另带 `counts{all, available, invalid}`。
+ * - 领取用 benefit_id(模板 id),使用用 user_benefit_id(实例 id)。
+ * 出参统一转成卡片模型:四态 status + stock_left + reason,
+ * 页面与组件只认这一套字段。
*/
import { USE_MOCK_CONTENT } from "@/config/env";
import { BENEFIT_STATUS, STORAGE_KEYS } from "@/constant";
import { clone } from "@/utils";
-import benefitsMock from "@/mock/benefits.json";
+import benefitsMock from "@/mock/v1/benefits.json";
+import meBenefitsMock from "@/mock/v1/me-benefits.json";
import * as benefitApi from "@/request/api/benefit";
-/** 本地权益状态:{ [id]: { claimed: true, use_count, last_used_at } } */
-const readLocalState = () => {
+/* ---------------- 接口态 → 卡片态 ---------------- */
+
+/** 固定有效期是否已过 */
+const isPast = (until) => {
+ if (!until) return false;
+ const t = Date.parse(until);
+ return !Number.isNaN(t) && t < Date.now();
+};
+
+/**
+ * 公开权益四态:
+ * 已领取 → 有效期未过 = 已领取、已过 = 已失效(接口的 `already_claimed` 不代表仍可用);
+ * 未领取 → 名额发完 = 已抢光,领取窗口已结束 / 权益失效 = 已失效,其余(含游客、未绑手机号)= 可领取。
+ */
+const publicStatus = (b) => {
+ if (b.already_claimed) return isPast(b.valid_until) ? BENEFIT_STATUS.EXPIRED : BENEFIT_STATUS.CLAIMED;
+ const reason = b.claim_unavailable_reason || "";
+ if (reason === "claim_limit_reached") return BENEFIT_STATUS.SOLD_OUT;
+ if (reason === "benefit_expired" || reason === "claim_ended") return BENEFIT_STATUS.EXPIRED;
+ return BENEFIT_STATUS.AVAILABLE;
+};
+
+/** 公开权益 → 卡片模型(详情页内嵌权益同用,保证两处判定一致) */
+export const toPublicCard = (b) => ({
+ ...b,
+ status: publicStatus(b),
+ reason: b.claim_unavailable_reason || "",
+ stock_left: b.total_limit == null ? null : Math.max(0, (b.total_limit || 0) - (b.claim_count || 0)),
+});
+
+/** 实例态 → 卡片态:可用记「已领取」,未生效 / 过期 / 次数用尽 / 作废统一灰化 */
+const MINE_STATUS = {
+ available: BENEFIT_STATUS.CLAIMED,
+ pending: BENEFIT_STATUS.CLAIMED,
+ expired: BENEFIT_STATUS.EXPIRED,
+ exhausted: BENEFIT_STATUS.EXPIRED,
+ voided: BENEFIT_STATUS.EXPIRED,
+};
+
+const toMineCard = (u) => ({
+ ...u,
+ // 使用接口要的就是 user_benefit_id,实例自身 id 即它
+ user_benefit_id: u.id,
+ status: MINE_STATUS[u.status] || BENEFIT_STATUS.CLAIMED,
+ reason: u.unavailable_reason || "",
+ can_use: !!u.can_use,
+ stock_left: null,
+});
+
+/* ---------------- mock 阶段:本地领取 / 使用状态 ---------------- */
+
+/**
+ * 没有服务端时,领取与使用要能反映到列表上,故本地叠一层实例状态:
+ * { [user_benefit_id]: { benefit_id, claimed_at, use_count, last_used_at } }
+ */
+const readLocal = () => {
try {
return uni.getStorageSync(STORAGE_KEYS.BENEFIT_STATE) || {};
} catch (e) {
@@ -21,122 +84,143 @@ const readLocalState = () => {
}
};
-const writeLocalState = (state) => {
- uni.setStorageSync(STORAGE_KEYS.BENEFIT_STATE, state || {});
-};
+const writeLocal = (state) => uni.setStorageSync(STORAGE_KEYS.BENEFIT_STATE, state || {});
-/** 合并基础数据与本地状态,得到「当前状态」 */
-const mergeState = (benefit, local) => {
- const patch = local[benefit.id];
- const next = { ...benefit, ...(patch || {}) };
- if (benefit.status === BENEFIT_STATUS.SOLD_OUT || benefit.status === BENEFIT_STATUS.EXPIRED) {
- next.status = benefit.status;
- } else if (patch?.claimed) {
- next.status = BENEFIT_STATUS.CLAIMED;
- }
- return next;
-};
+const nowIso = () => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
-const allWithState = () => {
- const local = readLocalState();
- return (benefitsMock || []).map((b) => mergeState(b, local));
-};
+const findLocal = (benefitId) =>
+ Object.entries(readLocal()).find(([, st]) => st.benefit_id === benefitId) || null;
-/** 合并本地领取状态(内容层的到店权益也走这里,保证「领取后按钮变去使用」一致) */
-export const applyLocalState = (list) => {
- const local = readLocalState();
- return (list || []).map((b) => mergeState(b, local));
-};
-
-/** 权益中心:可领取 + 已抢光(不含我的权益) */
-export const getBenefitList = async (params = {}) => {
- if (!USE_MOCK_CONTENT) return benefitApi.fetchBenefits(params);
- let list = allWithState().filter(
- (b) => b.status === BENEFIT_STATUS.AVAILABLE || b.status === BENEFIT_STATUS.SOLD_OUT
- );
- if (params.type && params.type !== "all") {
- list = list.filter((b) => b.type === params.type);
- }
- return clone(list);
-};
-
-/** 我的权益:claimed + expired */
-export const getMyBenefitList = async (params = {}) => {
- if (!USE_MOCK_CONTENT) return benefitApi.fetchMyBenefits(params);
- let list = allWithState().filter(
- (b) => b.status === BENEFIT_STATUS.CLAIMED || b.status === BENEFIT_STATUS.EXPIRED
- );
- if (params.tab === "usable") list = list.filter((b) => b.status === BENEFIT_STATUS.CLAIMED);
- if (params.tab === "invalid") list = list.filter((b) => b.status === BENEFIT_STATUS.EXPIRED);
- return clone(list);
-};
-
-/** 权益中心页头数据行文案 */
-export const getBenefitHeadline = async () => {
- const list = allWithState();
- const available = list.filter((b) => b.status === BENEFIT_STATUS.AVAILABLE).length;
- const claimed = list.filter((b) => b.status === BENEFIT_STATUS.CLAIMED).length;
- return `本周上新 ${available} 项 · 已领取 ${claimed} 项 · 剩余名额充足`;
-};
-
-/** 我的权益数量统计(全部 / 可使用 / 已失效) */
-export const getMyBenefitCounts = async () => {
- const list = (await getMyBenefitList()) || [];
+/** 套上本地领取状态后的公开权益(领取后按钮要变「去使用」) */
+const withLocalClaim = (b) => {
+ const hit = findLocal(b.id);
+ if (!hit) return b;
return {
- all: list.length,
- usable: list.filter((b) => b.status === BENEFIT_STATUS.CLAIMED).length,
- invalid: list.filter((b) => b.status === BENEFIT_STATUS.EXPIRED).length,
+ ...b,
+ already_claimed: true,
+ can_claim: false,
+ claim_unavailable_reason: "already_claimed",
+ user_benefit_id: hit[0],
};
};
-/** 商家关联的到店权益 */
-export const getRelatedBenefits = async (entityId) => {
- if (!USE_MOCK_CONTENT) return benefitApi.fetchRelatedBenefits(entityId);
- const list = allWithState().filter(
- (b) => b.related_entity_id === entityId && b.status !== BENEFIT_STATUS.EXPIRED
- );
- return clone(list);
+const mockCatalog = () => (benefitsMock.items || []).map(withLocalClaim);
+
+/** 由模板 + 本地状态合成一条已领取实例(形态同 UserBenefitView) */
+const buildInstance = (uid, st) => {
+ const tpl = (benefitsMock.items || []).find((t) => t.id === st.benefit_id) || {};
+ const expired = isPast(tpl.valid_until);
+ return {
+ id: uid,
+ user_id: "",
+ merchant_id: tpl.merchant_id || "",
+ merchant_name: tpl.merchant_name || "",
+ benefit_id: st.benefit_id,
+ title: tpl.title || "",
+ description: tpl.description || "",
+ image_url: tpl.image_url || "",
+ usage_limit: null,
+ use_count: st.use_count || 0,
+ last_used_at: st.last_used_at || null,
+ valid_from: st.claimed_at,
+ valid_until: tpl.valid_until || null,
+ claimed_at: st.claimed_at,
+ voided_at: null,
+ status: expired ? "expired" : "available",
+ can_use: !expired,
+ unavailable_reason: expired ? "expired" : "",
+ section_key: tpl.section_key || "",
+ price: tpl.price,
+ slogan: tpl.slogan,
+ scope_desc: tpl.scope_desc,
+ };
};
-/** 领取权益:写本地状态,不改变可用性质 */
-export const claimBenefit = async (id) => {
- if (!USE_MOCK_CONTENT) return benefitApi.claimBenefit(id);
- const local = readLocalState();
- local[id] = { ...(local[id] || {}), claimed: true };
- writeLocalState(local);
- return { id, status: BENEFIT_STATUS.CLAIMED };
+/** 我的权益实例 = 本地新领取的 + 预置的(按领取时间倒序) */
+const mockMineInstances = () => {
+ const local = readLocal();
+ const templates = benefitsMock.items || [];
+ const seededIds = new Set((meBenefitsMock.items || []).map((s) => s.id));
+ const seeded = (meBenefitsMock.items || []).map((s) => ({ ...s, ...(local[s.id] || {}) }));
+ const created = Object.entries(local)
+ // 只认目录里真有的模板,避免历史遗留 / 异常 storage 造出脏实例
+ .filter(([uid, st]) => !seededIds.has(uid) && templates.some((t) => t.id === st.benefit_id))
+ .map(([uid, st]) => buildInstance(uid, st));
+ return [...created, ...seeded].sort((a, b) => (a.claimed_at < b.claimed_at ? 1 : -1));
+};
+
+const countsOf = (instances) => ({
+ all: instances.length,
+ available: instances.filter((i) => i.status === "available").length,
+ invalid: instances.filter((i) => i.status === "expired" || i.status === "exhausted" || i.status === "voided")
+ .length,
+});
+
+/* ---------------- 对外接口 ---------------- */
+
+/** 权益中心:可领取 + 已抢光(已领取的归「我的权益」,不在中心重复出现) */
+export const getBenefitList = async (params = {}) => {
+ const type = params.type && params.type !== "all" ? params.type : "";
+ if (!USE_MOCK_CONTENT) {
+ const res = await benefitApi.fetchBenefits(type ? { section_key: type } : {});
+ return ((res && res.items) || []).map(toPublicCard).filter((b) => !b.already_claimed);
+ }
+ let list = mockCatalog().map(toPublicCard).filter((b) => !b.already_claimed);
+ if (type) list = list.filter((b) => b.section_key === type);
+ return clone(list);
};
/**
- * 记录一次使用:只累加次数与时间,**不使权益失效**(无幂等约束)
+ * 我的权益:items + counts。
+ * status 固定取 all 由前端切页签,保证 counts 是「全部」口径(服务端 counts 不受页签影响)。
*/
-export const useBenefit = async (id) => {
- if (!USE_MOCK_CONTENT) return benefitApi.useBenefit(id);
- const local = readLocalState();
- const prev = local[id] || { claimed: true };
- const next = {
- ...prev,
- claimed: true,
- use_count: (prev.use_count || 0) + 1,
- last_used_at: formatNow(),
- };
- local[id] = next;
- writeLocalState(local);
- return { id, ...next };
+export const getMyBenefitList = async () => {
+ if (!USE_MOCK_CONTENT) {
+ const res = await benefitApi.fetchMyBenefits({ status: "all" });
+ const items = ((res && res.items) || []).map(toMineCard);
+ const c = (res && res.counts) || {};
+ return {
+ items,
+ total: (res && res.total) || items.length,
+ counts: { all: c.all || 0, usable: c.available || 0, invalid: c.invalid || 0 },
+ };
+ }
+ const instances = mockMineInstances();
+ const c = countsOf(instances);
+ return { items: clone(instances.map(toMineCard)), total: c.all, counts: { all: c.all, usable: c.available, invalid: c.invalid } };
};
-const formatNow = () => {
- const d = new Date();
- const p = (n) => String(n).padStart(2, "0");
- return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
+/** 领取权益:入参是公开模板 id(benefit_id),返回领取后的实例 */
+export const claimBenefit = async (id) => {
+ if (!USE_MOCK_CONTENT) return toMineCard(await benefitApi.claimBenefit(id));
+ const local = readLocal();
+ const uid = `user-${id}`;
+ local[uid] = { benefit_id: id, claimed_at: nowIso(), use_count: 0, last_used_at: null };
+ writeLocal(local);
+ return clone(toMineCard(buildInstance(uid, local[uid])));
};
+/** 记录一次使用:入参是 user_benefit_id;只累加次数与时间,不使权益失效 */
+export const useBenefit = async (userBenefitId) => {
+ if (!USE_MOCK_CONTENT) return benefitApi.useBenefit(userBenefitId);
+ const local = readLocal();
+ const seed = local[userBenefitId] || mockMineInstances().find((i) => i.id === userBenefitId) || {};
+ local[userBenefitId] = {
+ benefit_id: seed.benefit_id || "",
+ claimed_at: seed.claimed_at || nowIso(),
+ use_count: (seed.use_count || 0) + 1,
+ last_used_at: nowIso(),
+ };
+ writeLocal(local);
+ return { id: `usage-${Date.now()}`, user_benefit_id: userBenefitId, used_at: nowIso() };
+};
+
+/** 合并本地领取状态(详情页内嵌权益同用,保证领取后按钮一致) */
+export const applyLocalState = (list) => (list || []).map(withLocalClaim);
+
export default {
getBenefitList,
getMyBenefitList,
- getBenefitHeadline,
- getMyBenefitCounts,
- getRelatedBenefits,
claimBenefit,
useBenefit,
applyLocalState,
diff --git a/src/api/content.js b/src/api/content.js
index a6ee03b..346d6b4 100644
--- a/src/api/content.js
+++ b/src/api/content.js
@@ -6,40 +6,24 @@
* false → 走 src/request/api/content.js 的真实 /v1 通道。
* 两条路返回的字段完全一致,切换只动这个开关。
*
- * 接口口径:一个页面链路由 4 个端点组成
- * GET /v1/homepage → GET /v1/categories?section_key= → GET /v1/merchants → GET /v1/merchants/:id/detail
- * 商家归属靠 category_id(分类接口 items[].id),不再有 collection 这一层。
+ * 接口口径分两块:
+ * 1)内容页链路,4 个端点
+ * GET /v1/homepage → GET /v1/categories?section_key= → GET /v1/merchants → GET /v1/merchants/:id/detail
+ * 商家归属靠 category_id(分类接口 items[].id),不再有 collection 这一层。
+ * 2)公共内容(协议 / 隐私 / 关于 / 客服)
+ * GET /v1/content(列表,登录同意版本号取这里) + GET /v1/content/:key(单条详情)
*/
+import { AGREEMENT_DATE, CUSTOMER_SERVICE_PHONE, CUSTOMER_SERVICE_TIME } from "@/constant";
import { USE_MOCK_CONTENT } from "@/config/env";
-import { BENEFIT_STATUS } from "@/constant";
-import { clone, unique } from "@/utils";
-import { applyLocalState } from "@/api/benefit";
+import { clone, cnDate, unique } from "@/utils";
+import { applyLocalState, toPublicCard } from "@/api/benefit";
import homepageMock from "@/mock/v1/homepage.json";
import categoriesMock from "@/mock/v1/categories.json";
import merchantsMock from "@/mock/v1/merchants.json";
import detailsMock from "@/mock/v1/merchant-details.json";
+import contentsMock from "@/mock/v1/contents.json";
import * as contentApi from "@/request/api/content";
-/**
- * 接口的权益只给「领取状态三件套」,页面四态与剩余名额由前端派生:
- * already_claimed(优先级最高)→ claimed;claim_limit_reached → sold_out;
- * benefit_expired → expired;其余 → available。
- */
-const deriveBenefitStatus = (b) => {
- if (b.already_claimed) return BENEFIT_STATUS.CLAIMED;
- const reason = b.claim_unavailable_reason || "";
- if (reason === "claim_limit_reached") return BENEFIT_STATUS.SOLD_OUT;
- if (reason === "benefit_expired") return BENEFIT_STATUS.EXPIRED;
- return BENEFIT_STATUS.AVAILABLE;
-};
-
-/** PublicBenefitView → 权益卡模型(status 四态 + stock_left 派生) */
-const toCardBenefit = (b) => ({
- ...b,
- status: deriveBenefitStatus(b),
- stock_left: b.total_limit == null ? null : Math.max(0, (b.total_limit || 0) - (b.claim_count || 0)),
-});
-
/** 首页聚合(品牌 / hero / 板块卡 / 精选推荐 / 导览 / 页脚) */
export const getHomepage = async () => {
if (!USE_MOCK_CONTENT) return contentApi.fetchHomepage();
@@ -64,7 +48,8 @@ export const getMerchants = async (params = {}) => {
/**
* 商家详情:一次返回商家 + 权益 + 同类推荐。
- * 内嵌权益是接口的 PublicBenefitView(status 恒为 published),出参前统一转成卡片模型。
+ * 内嵌权益是接口的 PublicBenefitView(status 恒为 published),
+ * 出参前统一转成卡片模型(四态 + 剩余名额),判定口径与权益中心同一套。
*/
export const getMerchantDetail = async (id) => {
let data;
@@ -76,10 +61,10 @@ export const getMerchantDetail = async (id) => {
}
if (!data) return null;
if (data.benefits) {
- const items = (data.benefits.items || []).map(toCardBenefit);
- // mock 阶段再叠一层本地领取状态,领取后按钮才能变「去使用」
- data.benefits.items = USE_MOCK_CONTENT ? applyLocalState(items) : items;
- data.benefits.total = items.length;
+ // mock 阶段先叠本地领取状态,再统一转卡片模型
+ const items = USE_MOCK_CONTENT ? applyLocalState(data.benefits.items || []) : data.benefits.items || [];
+ data.benefits.items = items.map(toPublicCard);
+ data.benefits.total = data.benefits.items.length;
}
return data;
};
@@ -104,6 +89,107 @@ export const getSectionBannerImages = async (sectionKey) => {
return unique(urls.filter(Boolean)).slice(0, 3);
};
+/* ---------------------------------------------------------------------------
+ * 公共内容:协议 / 隐私 / 关于 / 客服
+ * 接口的 body 是纯文本,前端统一按行拆成段落,`一、二、…` 行作为小标题。
+ * 接口缺失(未配置 / 未启用 / 不在有效窗口 → 404)时回落仓库内的定稿正文,
+ * 保证合规页不空白。
+ * ------------------------------------------------------------------------- */
+
+const FALLBACK_TITLE = {
+ terms: "用户协议",
+ privacy: "隐私政策",
+};
+
+/** body → [{ type: "h5" | "p", text }](兼容纯文本与简单 HTML) */
+export const toContentBlocks = (body) => {
+ const text = String(body || "").trim();
+ if (!text) return [];
+ return text
+ .replace(/<\/(?:h[1-6]|p|div)>/gi, "\n")
+ .replace(/
/gi, "\n")
+ .replace(/<[^>]+>/g, "")
+ .split(/\n+/)
+ .map((s) => s.trim())
+ .filter(Boolean)
+ .map((s) => ({ type: /^[一二三四五六七八九十]+、/.test(s) ? "h5" : "p", text: s }));
+};
+
+/** body → 段落数组(关于我们这类整段文案) */
+const toParagraphs = (body) => toContentBlocks(body).map((b) => b.text);
+
+/** 定稿兜底:接口取不到就用仓库内文本 */
+const fallbackContent = (key) => clone((contentsMock.items || []).find((c) => c.key === key) || null);
+
+/**
+ * 登录同意项:当前生效的 terms / privacy 版本号(整表取一次)。
+ * ⚠️ 这里**不用本地定稿兜底**:版本号必须以服务端为准,
+ * 拿不到就不提交 consents,由服务端按「尚无有效协议记录」放行或报 403。
+ */
+export const getConsentVersions = async () => {
+ let list;
+ if (USE_MOCK_CONTENT) list = clone(contentsMock);
+ else {
+ try {
+ list = await contentApi.fetchContents();
+ } catch (e) {
+ return [];
+ }
+ }
+ return ["terms", "privacy"]
+ .map((key) => {
+ const hit = ((list && list.items) || []).find((c) => c.key === key);
+ return hit && hit.version ? { key, version: hit.version } : null;
+ })
+ .filter(Boolean);
+};
+
+/** 单条内容缓存:公共内容不常变,切页重复进入不再重复请求 */
+const contentCache = {};
+
+/** 公共内容详情:key ∈ terms | privacy | about | customer_service | brand */
+export const getContent = async (key) => {
+ if (contentCache[key]) return clone(contentCache[key]);
+ let info = null;
+ if (USE_MOCK_CONTENT) {
+ info = fallbackContent(key);
+ } else {
+ try {
+ info = (await contentApi.fetchContent(key)) || fallbackContent(key);
+ } catch (e) {
+ info = fallbackContent(key);
+ }
+ }
+ if (info) contentCache[key] = info;
+ return clone(info);
+};
+
+/** 协议页:标题 + 正文段落 + 更新日期 */
+export const getAgreement = async (type = "privacy") => {
+ const info = await getContent(type);
+ return {
+ title: (info && info.title) || FALLBACK_TITLE[type] || "",
+ blocks: toContentBlocks(info && info.body),
+ updatedAt: cnDate(info && info.updated_at) || AGREEMENT_DATE,
+ };
+};
+
+/** 关于我们页:介绍段落 + 联系方式(接口缺字段时回落本地常量) */
+export const getAbout = async () => {
+ const info = await getContent("about");
+ return {
+ intro: toParagraphs(info && info.body),
+ phone: (info && info.phone) || CUSTOMER_SERVICE_PHONE,
+ serviceHours: (info && info.service_hours) || CUSTOMER_SERVICE_TIME,
+ };
+};
+
+/** 联系客服:拨号号码(一期直接 makePhoneCall,不展示正文) */
+export const getCustomerService = async () => {
+ const info = await getContent("customer_service");
+ return { phone: (info && info.phone) || CUSTOMER_SERVICE_PHONE };
+};
+
export default {
getHomepage,
getCategories,
@@ -111,4 +197,9 @@ export default {
getMerchantDetail,
getSectionCover,
getSectionBannerImages,
+ getContent,
+ getConsentVersions,
+ getAgreement,
+ getAbout,
+ getCustomerService,
};
diff --git a/src/api/user.js b/src/api/user.js
index d0d2508..ddafefa 100644
--- a/src/api/user.js
+++ b/src/api/user.js
@@ -1,44 +1,50 @@
/**
- * 用户 / 登录数据访问层
+ * 用户 / 登录 / 本人资料 / 注销 数据访问层(唯一数据入口)
* ---------------------------------------------------------------------------
- * 真实接口,链路照搬 YGChatCS 的 manager/LoginManager.js + hooks/useGoLogin.js。
- * 网关鉴权靠请求头 clientId = "2"(见 src/config/env.js),不再单独造 mock 用户。
+ * 页面与 store 只调用本文件,不直接 import mock。
+ * USE_MOCK_USER = true → 读 src/mock/v1/*(已是 /v1 接口响应形态,无需再适配);
+ * false → 走 src/request/api/me.js 的真实 /v1 通道。
+ * 两条路返回的字段完全一致,切换只动这个开关。
*
- * 链路:
- * 1. uni.login 拿 code
- * → POST /auth/oauth2/token(Authorization: Basic custom:custom,form-urlencoded)
- * body { openIdCode: [code], grant_type: "wechat", clientId, scope: "server" }
- * → access_token / refresh_token 落 storage
- * 2. GET /hotelBiz/user/checkUserHasBindPhone → { data: boolean }
- * 3. 未绑定 → POST /hotelBiz/user/bindUserPhone { wechatPhoneCode, clientId }
+ * 接口口径:
+ * POST /v1/auth/wechat → { token, user, expires_at }
+ * GET /v1/me → UserView
+ * POST /v1/me/phone { code } → UserView
+ * PATCH/v1/me/profile { nickname?, avatar_url? } → UserView
+ * POST /v1/me/avatar multipart(file) → { image_url, ... }
+ * POST /v1/me/logout {} → { logged_out }
+ * POST /v1/me/erasure { confirm: true } → { status_token, status, retryable }
+ * GET /v1/account-erasure (回执鉴权)→ { status, retryable }
+ * POST /v1/account-erasure/retry (回执鉴权)→ { status, retryable }
*
- * 边界说明(避免再造假数据):
- * - 昵称 / 头像:后端无对应接口,改由用户主动提供(chooseAvatar + 昵称输入),只写本地
- * storage;不做任何「李龙 / 13888880000」式占位数据,未设置时页面自行显示兜底文案。
- * - 账户注销:后端暂无注销接口(参考项目也没有),一期只清本地登录态与权益记录。
- * - 协议正文:仍取本地定稿文本(USE_MOCK_CONTENT),真实接口已备好,就绪后翻开关即可。
+ * 出参统一转成页面内部模型(nickName / avatarUrl / phone / phoneBound),
+ * 页面不感知 UserView 的下划线字段。
*/
-import { CLIENT_ID, USE_MOCK_CONTENT, USE_MOCK_USER } from "@/config/env";
-import {
- getRefreshToken,
- getUserInfo,
- removeAccessToken,
- setAccessToken,
- setRefreshToken,
-} from "@/constant/token";
+import { USE_MOCK_USER } from "@/config/env";
+import { STORAGE_KEYS } from "@/constant";
import { clone } from "@/utils";
-import { ensureEnvReady } from "@/request/base/config";
-import * as loginApi from "@/request/api/login";
+import loginMock from "@/mock/v1/login.json";
+import meMock from "@/mock/v1/me.json";
+import erasureMock from "@/mock/v1/erasure.json";
+import erasureStatusMock from "@/mock/v1/erasure-status.json";
+import * as meApi from "@/request/api/me";
-/** 仅 USE_MOCK_USER = true 的离线调试用;默认关闭 */
-const MOCK_USER = {
- id: "longli-mock-user",
- nickName: "",
- avatarUrl: "",
- phone: "",
- phoneBound: false,
+/** 接口 UserView → 页面内部用户模型 */
+const toUserInfo = (v) => {
+ if (!v) return null;
+ return {
+ id: v.id || "",
+ nickName: v.nickname || "",
+ avatarUrl: v.avatar_url || "",
+ // 接口只回脱敏号,页面直接展示(maskPhone 对已脱敏串原样返回)
+ phone: v.phone_masked || "",
+ phoneBound: !!v.phone_bound,
+ };
};
+/** mock 绑定手机号后的用户视图(真实接口由服务端回吐) */
+const MOCK_BOUND_USER = { ...meMock, phone_bound: true, phone_masked: "+86138****8000" };
+
/** 调 wx.login 拿 code(真实链路的第一步) */
export const getWxCode = () =>
new Promise((resolve, reject) => {
@@ -53,221 +59,139 @@ export const getWxCode = () =>
});
});
-/** token 成对落盘(服务端每次都会回新的 refresh_token) */
-const saveToken = (res = {}) => {
- setAccessToken(res.access_token);
- setRefreshToken(res.refresh_token);
+/**
+ * 微信一键登录
+ * @param {Array<{key: string, version: number}>} consents 已同意的 terms / privacy 版本
+ * @returns {Promise<{ token: string, userInfo: object|null }>}
+ */
+export const wxLogin = async (consents = []) => {
+ if (USE_MOCK_USER) {
+ const res = clone(loginMock);
+ return { token: res.token, userInfo: toUserInfo(res.user) };
+ }
+ const code = await getWxCode();
+ const body = { code };
+ if (consents.length) body.consents = consents;
+ const res = await meApi.wechatLogin(body);
+ return { token: res.token, userInfo: toUserInfo(res.user) };
};
-/** code / refresh_token 换 token,失败抛错由调用方 toast */
-const requestToken = async (params) => {
- const res = await loginApi.oauthToken(params);
- if (!res || !res.access_token) {
- throw new Error((res && (res.message || res.msg)) || "登录失败");
+/** 当前用户视图(昵称 / 头像 / 绑定态一次拿全) */
+export const fetchMe = async () => {
+ if (USE_MOCK_USER) return toUserInfo(clone(meMock));
+ return toUserInfo(await meApi.fetchMe());
+};
+
+/**
+ * 绑定手机号(getPhoneNumber 回调的手机号授权 code)
+ * @returns {Promise