feat: 后端补齐前端所需模块 + 后台登录页品牌化重构
后端(补齐前端已入口但无接口的模块): - Coupon 控制器:center 领券中心 / my 我的券(未使用·已使用·已过期+计数)/ receive 领取 含库存与过期校验、重复领取拦截、领券后自动发消息 - Message 控制器:index 列表(分类筛选)/ read 单条已读 / readAll 全部已读 / unread 角标 支持 user 与 master 两种归属 前端(对应页面,全部接真接口): - pages/address/list 服务地址:列表 / 新增·编辑弹层 / 设为默认 / 删除 - pages/coupon/coupon 领券中心 + 我的券(双 Tab + 状态筛选 + 计数) - pages/message/message 消息中心:分类 / 未读圆点 / 点开自动已读 / 全部已读 / 跳订单 - mine.vue 三个入口(地址·优惠券·消息)由「开发中」改为真实路由 - merchant/workbench 服务地址入口接真实路由 - 首页 TabBar「消息」接入消息中心 - api.js 新增 coupon/message 共 7 个接口 后台登录页(UI 与前端主色调统一): - 左侧橙金渐变品牌区:logo 徽标 + 「优匠优服」立体字 + slogan + 460万+/99.9%/1762万 平台实力 + 保障条 - 右侧白色登录卡:橙色渐变按钮、圆角输入框、错误提示条 - 保留 FastAdmin 全部挂钩(token/验证码/keeplogin/@AdminLoginFormBegin 等 14 项) 验证:5 套用例 178 项断言全通过(47+65+27+24+15),配置自检 8/8
这个提交包含在:
@@ -290,6 +290,11 @@
|
|||||||
uni.navigateTo({ url: '/pages/order/list' })
|
uni.navigateTo({ url: '/pages/order/list' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (i === 2) {
|
||||||
|
if (!needLogin()) return
|
||||||
|
uni.navigateTo({ url: '/pages/message/message' })
|
||||||
|
return
|
||||||
|
}
|
||||||
if (i === 3) {
|
if (i === 3) {
|
||||||
onUser()
|
onUser()
|
||||||
return
|
return
|
||||||
|
|||||||
16
pages.json
16
pages.json
@@ -38,6 +38,22 @@
|
|||||||
"path": "pages/order/review",
|
"path": "pages/order/review",
|
||||||
"style": { "navigationBarTitleText": "服务评价" }
|
"style": { "navigationBarTitleText": "服务评价" }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/address/list",
|
||||||
|
"style": { "navigationBarTitleText": "服务地址" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/coupon/coupon",
|
||||||
|
"style": { "navigationBarTitleText": "优惠券" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/message/message",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "消息中心",
|
||||||
|
"enablePullDownRefresh": true,
|
||||||
|
"backgroundTextStyle": "dark"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/master/apply",
|
"path": "pages/master/apply",
|
||||||
"style": { "navigationBarTitleText": "师傅入驻" }
|
"style": { "navigationBarTitleText": "师傅入驻" }
|
||||||
|
|||||||
167
pages/address/list.vue
普通文件
167
pages/address/list.vue
普通文件
@@ -0,0 +1,167 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<view class="list">
|
||||||
|
<view class="item" v-for="a in list" :key="a.id">
|
||||||
|
<view class="item-top">
|
||||||
|
<text class="contact">{{ a.contact }}</text>
|
||||||
|
<text class="mobile">{{ a.mobile }}</text>
|
||||||
|
<text class="badge" v-if="Number(a.is_default)">默认</text>
|
||||||
|
</view>
|
||||||
|
<text class="addr">{{ a.province }}{{ a.city }}{{ a.district }}{{ a.address }}</text>
|
||||||
|
<text class="meta">楼层 {{ a.floor }} · {{ Number(a.has_elevator) ? '有电梯' : '无电梯' }}</text>
|
||||||
|
<view class="ops">
|
||||||
|
<view class="op" v-if="!Number(a.is_default)" @tap="setDefault(a)">设为默认</view>
|
||||||
|
<view class="op" @tap="edit(a)">编辑</view>
|
||||||
|
<view class="op danger" @tap="remove(a)">删除</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="empty" v-if="!loading && !list.length">还没有服务地址,先添加一个吧</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="bar">
|
||||||
|
<view class="bar-btn" @tap="openForm()">+ 新增服务地址</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="mask" v-if="showForm" @tap="showForm = false">
|
||||||
|
<view class="sheet" @tap.stop>
|
||||||
|
<view class="sheet-head">
|
||||||
|
<text class="sheet-title">{{ form.id ? '编辑地址' : '新增地址' }}</text>
|
||||||
|
<text class="sheet-close" @tap="showForm = false">关闭</text>
|
||||||
|
</view>
|
||||||
|
<view class="fi"><text class="fk">联系人</text><input class="fv" v-model="form.contact" placeholder="收货人姓名" /></view>
|
||||||
|
<view class="fi"><text class="fk">手机号</text><input class="fv" v-model="form.mobile" type="number" maxlength="11" placeholder="联系手机号" /></view>
|
||||||
|
<view class="fi"><text class="fk">详细地址</text><input class="fv" v-model="form.address" placeholder="小区/楼栋/门牌号" /></view>
|
||||||
|
<view class="fi"><text class="fk">楼层</text>
|
||||||
|
<view class="stepper">
|
||||||
|
<view class="st-btn" @tap="step(-1)">-</view>
|
||||||
|
<text class="st-val">{{ form.floor }}</text>
|
||||||
|
<view class="st-btn" @tap="step(1)">+</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="fi"><text class="fk">有无电梯</text>
|
||||||
|
<view class="seg">
|
||||||
|
<view class="seg-i" :class="{ on: form.has_elevator === 1 }" @tap="form.has_elevator = 1">有</view>
|
||||||
|
<view class="seg-i" :class="{ on: form.has_elevator === 0 }" @tap="form.has_elevator = 0">无</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="fi"><text class="fk">设为默认</text>
|
||||||
|
<view class="seg">
|
||||||
|
<view class="seg-i" :class="{ on: form.is_default === 1 }" @tap="form.is_default = 1">是</view>
|
||||||
|
<view class="seg-i" :class="{ on: form.is_default === 0 }" @tap="form.is_default = 0">否</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="save" :class="{ disabled: saving }" @tap="save">{{ saving ? '保存中...' : '保存' }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive } from 'vue';
|
||||||
|
import { onShow } from '@dcloudio/uni-app';
|
||||||
|
import api from '@/utils/api.js';
|
||||||
|
import { isLogin, toLogin } from '@/utils/request.js';
|
||||||
|
|
||||||
|
const list = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const showForm = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const form = reactive({ id: 0, contact: '', mobile: '', address: '', floor: 1, has_elevator: 1, is_default: 0 });
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!isLogin()) { toLogin(); return; }
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await api.addressList();
|
||||||
|
list.value = res.list || [];
|
||||||
|
} catch (e) {
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openForm(a) {
|
||||||
|
form.id = a ? Number(a.id) : 0;
|
||||||
|
form.contact = a ? a.contact : '';
|
||||||
|
form.mobile = a ? a.mobile : '';
|
||||||
|
form.address = a ? a.address : '';
|
||||||
|
form.floor = a ? Number(a.floor) : 1;
|
||||||
|
form.has_elevator = a ? Number(a.has_elevator) : 1;
|
||||||
|
form.is_default = a ? Number(a.is_default) : 0;
|
||||||
|
showForm.value = true;
|
||||||
|
}
|
||||||
|
function edit(a) { openForm(a); }
|
||||||
|
function step(d) { form.floor = Math.max(1, form.floor + d); }
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (saving.value) return;
|
||||||
|
if (!form.contact || !form.mobile || !form.address) {
|
||||||
|
uni.showToast({ title: '请填写联系人/手机号/地址', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
await api.addressSave({
|
||||||
|
id: form.id, contact: form.contact, mobile: form.mobile,
|
||||||
|
province: '贵州省', city: '遵义市', district: '', address: form.address,
|
||||||
|
floor: form.floor, has_elevator: form.has_elevator, is_default: form.is_default
|
||||||
|
});
|
||||||
|
uni.showToast({ title: '已保存' });
|
||||||
|
showForm.value = false;
|
||||||
|
load();
|
||||||
|
} catch (e) {
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setDefault(a) {
|
||||||
|
try { await api.addressDefault(a.id); uni.showToast({ title: '已设为默认' }); load(); } catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(a) {
|
||||||
|
uni.showModal({
|
||||||
|
title: '删除地址', content: '确认删除该地址?',
|
||||||
|
success: async (r) => {
|
||||||
|
if (!r.confirm) return;
|
||||||
|
try { await api.addressDelete(a.id); uni.showToast({ title: '已删除' }); load(); } catch (e) {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onShow(() => load());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page { min-height: 100vh; background: #F5F6F8; padding: 22rpx 28rpx 180rpx; }
|
||||||
|
.item { background: #fff; border-radius: 20rpx; padding: 22rpx 24rpx; margin-bottom: 20rpx; box-shadow: 0 6rpx 20rpx rgba(240,129,31,.06); }
|
||||||
|
.item-top { display: flex; align-items: center; }
|
||||||
|
.contact { font-size: 28rpx; font-weight: 600; color: #1F1F1F; }
|
||||||
|
.mobile { margin-left: 16rpx; font-size: 24rpx; color: #666; }
|
||||||
|
.badge { margin-left: 12rpx; padding: 0 12rpx; line-height: 32rpx; border-radius: 8rpx; background: #FFF3E6; color: #E36B0C; font-size: 20rpx; }
|
||||||
|
.addr { display: block; margin-top: 10rpx; font-size: 26rpx; color: #333; line-height: 36rpx; }
|
||||||
|
.meta { display: block; margin-top: 6rpx; font-size: 22rpx; color: #9A9A9A; }
|
||||||
|
.ops { display: flex; justify-content: flex-end; margin-top: 14rpx; padding-top: 14rpx; border-top: 1rpx solid #F4F4F4; }
|
||||||
|
.op { margin-left: 16rpx; padding: 0 24rpx; line-height: 58rpx; border-radius: 29rpx; border: 2rpx solid #EEE; font-size: 24rpx; color: #666; }
|
||||||
|
.op.danger { color: #E8331B; border-color: #F8D9D4; }
|
||||||
|
.empty { padding: 110rpx 0; text-align: center; font-size: 24rpx; color: #9A9A9A; }
|
||||||
|
.bar { position: fixed; left: 0; right: 0; bottom: 0; height: 120rpx; padding: 0 28rpx; padding-bottom: env(safe-area-inset-bottom); display: flex; align-items: center; background: #fff; box-shadow: 0 -6rpx 24rpx rgba(0,0,0,.07); }
|
||||||
|
.bar-btn { flex: 1; height: 84rpx; display: flex; align-items: center; justify-content: center; border-radius: 42rpx; color: #fff; font-size: 30rpx; font-weight: 600; background: linear-gradient(135deg,#FFA93F 0%,#F0710D 100%); }
|
||||||
|
.mask { position: fixed; left: 0; right: 0; top: 0; bottom: 0; background: rgba(0,0,0,.45); display: flex; align-items: flex-end; z-index: 99; }
|
||||||
|
.sheet { width: 100%; background: #fff; border-radius: 24rpx 24rpx 0 0; padding: 26rpx 28rpx calc(30rpx + env(safe-area-inset-bottom)); }
|
||||||
|
.sheet-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12rpx; }
|
||||||
|
.sheet-title { font-size: 30rpx; font-weight: 700; color: #1F1F1F; }
|
||||||
|
.sheet-close { font-size: 24rpx; color: #9A9A9A; }
|
||||||
|
.fi { display: flex; align-items: center; min-height: 88rpx; border-bottom: 1rpx solid #F4F4F4; }
|
||||||
|
.fk { width: 160rpx; font-size: 26rpx; color: #666; }
|
||||||
|
.fv { flex: 1; height: 88rpx; font-size: 28rpx; color: #222; }
|
||||||
|
.stepper { display: flex; align-items: center; }
|
||||||
|
.st-btn { width: 52rpx; height: 52rpx; line-height: 50rpx; text-align: center; border-radius: 26rpx; background: #F5F5F5; color: #666; font-size: 26rpx; }
|
||||||
|
.st-val { min-width: 60rpx; text-align: center; font-size: 28rpx; color: #222; }
|
||||||
|
.seg { display: flex; }
|
||||||
|
.seg-i { padding: 0 26rpx; line-height: 52rpx; margin-right: 12rpx; border-radius: 26rpx; background: #F5F5F5; font-size: 24rpx; color: #666; }
|
||||||
|
.seg-i.on { background: linear-gradient(135deg,#FFA93F 0%,#F0710D 100%); color: #fff; }
|
||||||
|
.save { height: 88rpx; margin-top: 22rpx; display: flex; align-items: center; justify-content: center; border-radius: 44rpx; color: #fff; font-size: 30rpx; font-weight: 600; background: linear-gradient(135deg,#FFA93F 0%,#F0710D 100%); }
|
||||||
|
.save.disabled { opacity: .7; }
|
||||||
|
</style>
|
||||||
|
|
||||||
132
pages/coupon/coupon.vue
普通文件
132
pages/coupon/coupon.vue
普通文件
@@ -0,0 +1,132 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<view class="tabs">
|
||||||
|
<view class="tab" :class="{ on: tab === 'center' }" @tap="switchTab('center')">领券中心</view>
|
||||||
|
<view class="tab" :class="{ on: tab === 'mine' }" @tap="switchTab('mine')">我的券{{ counts.unused ? '(' + counts.unused + ')' : '' }}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="list" v-if="tab === 'center'">
|
||||||
|
<view class="cp" v-for="c in center" :key="c.id">
|
||||||
|
<view class="cp-l">
|
||||||
|
<text class="cp-val">{{ c.value_text }}</text>
|
||||||
|
<text class="cp-rule">{{ c.rule_text }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="cp-m">
|
||||||
|
<text class="cp-name">{{ c.name }}</text>
|
||||||
|
<text class="cp-time">{{ c.time_text }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="cp-btn" :class="{ done: c.received_by_me || c.sold_out }" @tap="receive(c)">
|
||||||
|
{{ c.received_by_me ? '已领取' : (c.sold_out ? '已领完' : '领取') }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="empty" v-if="!loading && !center.length">暂无可领取的优惠券</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="list" v-else>
|
||||||
|
<view class="tabs2">
|
||||||
|
<view v-for="t in statusTabs" :key="t.key" class="tab2" :class="{ on: status === t.key }" @tap="switchStatus(t.key)">
|
||||||
|
{{ t.name }}{{ counts[t.key] ? ' ' + counts[t.key] : '' }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="cp" :class="{ dim: status !== 'unused' }" v-for="c in mine" :key="c.id">
|
||||||
|
<view class="cp-l">
|
||||||
|
<text class="cp-val">{{ c.value_text }}</text>
|
||||||
|
<text class="cp-rule">{{ c.rule_text }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="cp-m">
|
||||||
|
<text class="cp-name">{{ c.name }}</text>
|
||||||
|
<text class="cp-time">{{ c.time_text }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="cp-btn use" v-if="status === 'unused'" @tap="goOrder">去使用</view>
|
||||||
|
</view>
|
||||||
|
<view class="empty" v-if="!loading && !mine.length">暂无优惠券</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { onShow } from '@dcloudio/uni-app';
|
||||||
|
import api from '@/utils/api.js';
|
||||||
|
import { isLogin, toLogin } from '@/utils/request.js';
|
||||||
|
|
||||||
|
const tab = ref('center');
|
||||||
|
const status = ref('unused');
|
||||||
|
const center = ref([]);
|
||||||
|
const mine = ref([]);
|
||||||
|
const counts = ref({ unused: 0, used: 0, expired: 0 });
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const statusTabs = [
|
||||||
|
{ key: 'unused', name: '未使用' },
|
||||||
|
{ key: 'used', name: '已使用' },
|
||||||
|
{ key: 'expired', name: '已过期' }
|
||||||
|
];
|
||||||
|
|
||||||
|
function switchTab(t) {
|
||||||
|
tab.value = t;
|
||||||
|
if (t === 'center') loadCenter(); else loadMine();
|
||||||
|
}
|
||||||
|
function switchStatus(s) { status.value = s; loadMine(); }
|
||||||
|
function goOrder() { uni.reLaunch({ url: '/YJYF/YJYF' }); }
|
||||||
|
|
||||||
|
async function loadCenter() {
|
||||||
|
if (!isLogin()) { toLogin(); return; }
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await api.couponCenter();
|
||||||
|
center.value = res.list || [];
|
||||||
|
} catch (e) {
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMine() {
|
||||||
|
if (!isLogin()) { toLogin(); return; }
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await api.couponMy({ status: status.value });
|
||||||
|
mine.value = res.list || [];
|
||||||
|
if (res.counts) counts.value = res.counts;
|
||||||
|
} catch (e) {
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function receive(c) {
|
||||||
|
if (c.received_by_me) { uni.showToast({ title: '已领取过了', icon: 'none' }); return; }
|
||||||
|
if (c.sold_out) { uni.showToast({ title: '已被领完', icon: 'none' }); return; }
|
||||||
|
try {
|
||||||
|
await api.couponReceive({ coupon_id: c.id });
|
||||||
|
uni.showToast({ title: '领取成功' });
|
||||||
|
loadCenter();
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
onShow(() => loadCenter());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page { min-height: 100vh; background: #F5F6F8; padding-bottom: 40rpx; }
|
||||||
|
.tabs { display: flex; background: #fff; box-shadow: 0 4rpx 12rpx rgba(0,0,0,.03); }
|
||||||
|
.tab { flex: 1; text-align: center; padding: 26rpx 0; font-size: 28rpx; color: #666; }
|
||||||
|
.tab.on { color: #F0710D; font-weight: 700; }
|
||||||
|
.tabs2 { display: flex; padding: 0 0 16rpx; }
|
||||||
|
.tab2 { margin-right: 16rpx; padding: 0 24rpx; line-height: 56rpx; border-radius: 28rpx; background: #fff; font-size: 24rpx; color: #666; }
|
||||||
|
.tab2.on { background: #FFF3E6; color: #E36B0C; font-weight: 600; }
|
||||||
|
.list { padding: 22rpx 28rpx; }
|
||||||
|
.cp { display: flex; align-items: center; background: #fff; border-radius: 20rpx; padding: 24rpx; margin-bottom: 18rpx; box-shadow: 0 6rpx 20rpx rgba(240,129,31,.06); }
|
||||||
|
.cp.dim { opacity: .6; }
|
||||||
|
.cp-l { width: 150rpx; flex-shrink: 0; display: flex; flex-direction: column; align-items: center; padding-right: 18rpx; border-right: 1rpx dashed #F0DCC8; }
|
||||||
|
.cp-val { font-size: 40rpx; font-weight: 800; color: #F0710D; }
|
||||||
|
.cp-rule { margin-top: 4rpx; font-size: 18rpx; color: #B5B5B5; }
|
||||||
|
.cp-m { flex: 1; min-width: 0; padding-left: 18rpx; display: flex; flex-direction: column; }
|
||||||
|
.cp-name { font-size: 28rpx; font-weight: 600; color: #1F1F1F; }
|
||||||
|
.cp-time { margin-top: 6rpx; font-size: 20rpx; color: #9A9A9A; }
|
||||||
|
.cp-btn { flex-shrink: 0; margin-left: 12rpx; padding: 0 26rpx; line-height: 60rpx; border-radius: 30rpx; color: #fff; font-size: 24rpx; font-weight: 600; background: linear-gradient(135deg,#FFA93F 0%,#F0710D 100%); }
|
||||||
|
.cp-btn.done { background: #EEE; color: #9A9A9A; }
|
||||||
|
.cp-btn.use { background: #FFF3E6; color: #E36B0C; }
|
||||||
|
.empty { padding: 100rpx 0; text-align: center; font-size: 24rpx; color: #9A9A9A; }
|
||||||
|
</style>
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
{ key: 'orders', name: '企业订单', icon: '单', bg: '#EAF2FF', color: '#4A7CFF', action: () => go('/pages/order/list') },
|
{ key: 'orders', name: '企业订单', icon: '单', bg: '#EAF2FF', color: '#4A7CFF', action: () => go('/pages/order/list') },
|
||||||
{ key: 'invoice', name: '开具发票', icon: '票', bg: '#E8F7EE', color: '#2FA84F', action: () => go('/pages/merchant/invoice') },
|
{ key: 'invoice', name: '开具发票', icon: '票', bg: '#E8F7EE', color: '#2FA84F', action: () => go('/pages/merchant/invoice') },
|
||||||
{ key: 'profile', name: '企业资料', icon: '档', bg: '#F0EBFF', color: '#7A5AF8', action: () => go('/pages/merchant/apply') },
|
{ key: 'profile', name: '企业资料', icon: '档', bg: '#F0EBFF', color: '#7A5AF8', action: () => go('/pages/merchant/apply') },
|
||||||
{ key: 'address', name: '服务地址', icon: '址', bg: '#FFF0F0', color: '#E8331B', action: () => uni.showToast({ title: '地址管理开发中', icon: 'none' }) },
|
{ key: 'address', name: '服务地址', icon: '址', bg: '#FFF0F0', color: '#E8331B', action: () => go('/pages/address/list') },
|
||||||
{ key: 'service', name: '联系客服', icon: '服', bg: '#EAF6FF', color: '#2A8FD8', action: () => uni.makePhoneCall({ phoneNumber: '4000000000' }) }
|
{ key: 'service', name: '联系客服', icon: '服', bg: '#EAF6FF', color: '#2A8FD8', action: () => uni.makePhoneCall({ phoneNumber: '4000000000' }) }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
93
pages/message/message.vue
普通文件
93
pages/message/message.vue
普通文件
@@ -0,0 +1,93 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<view class="head">
|
||||||
|
<view class="cats">
|
||||||
|
<view v-for="c in cats" :key="c.key" class="cat" :class="{ on: category === c.key }" @tap="switchCat(c.key)">{{ c.name }}</view>
|
||||||
|
</view>
|
||||||
|
<text class="read-all" @tap="readAll">全部已读</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="list">
|
||||||
|
<view class="msg" :class="{ unread: !m.is_read }" v-for="m in list" :key="m.id" @tap="open(m)">
|
||||||
|
<view class="msg-top">
|
||||||
|
<view class="dot" v-if="!m.is_read"></view>
|
||||||
|
<text class="title">{{ m.title }}</text>
|
||||||
|
<text class="time">{{ m.time_text }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="content">{{ m.content }}</text>
|
||||||
|
<text class="go" v-if="m.order_id">查看订单 ›</text>
|
||||||
|
</view>
|
||||||
|
<view class="empty" v-if="!loading && !list.length">暂无消息</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app';
|
||||||
|
import api from '@/utils/api.js';
|
||||||
|
import { isLogin, toLogin } from '@/utils/request.js';
|
||||||
|
|
||||||
|
const list = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const category = ref('all');
|
||||||
|
const cats = [
|
||||||
|
{ key: 'all', name: '全部' },
|
||||||
|
{ key: 'order', name: '订单' },
|
||||||
|
{ key: 'system', name: '系统' },
|
||||||
|
{ key: 'remind', name: '提醒' }
|
||||||
|
];
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!isLogin()) { toLogin(); return; }
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await api.messageList({ category: category.value, limit: 30 });
|
||||||
|
list.value = res.list || [];
|
||||||
|
} catch (e) {
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchCat(k) { category.value = k; load(); }
|
||||||
|
|
||||||
|
async function open(m) {
|
||||||
|
if (!m.is_read) {
|
||||||
|
try { await api.messageRead({ id: m.id }); m.is_read = 1; } catch (e) {}
|
||||||
|
}
|
||||||
|
if (m.order_id) {
|
||||||
|
uni.navigateTo({ url: '/pages/order/detail?id=' + m.order_id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readAll() {
|
||||||
|
try {
|
||||||
|
const res = await api.messageReadAll({});
|
||||||
|
uni.showToast({ title: '已全部标记已读(' + res.count + ' 条)', icon: 'none' });
|
||||||
|
load();
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
onShow(() => load());
|
||||||
|
onPullDownRefresh(() => { load().then(() => uni.stopPullDownRefresh()); });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page { min-height: 100vh; background: #F5F6F8; padding-bottom: 40rpx; }
|
||||||
|
.head { display: flex; align-items: center; background: #fff; padding: 20rpx 24rpx; box-shadow: 0 4rpx 12rpx rgba(0,0,0,.03); }
|
||||||
|
.cats { flex: 1; display: flex; }
|
||||||
|
.cat { margin-right: 14rpx; padding: 0 22rpx; line-height: 54rpx; border-radius: 27rpx; background: #F5F5F5; font-size: 24rpx; color: #666; }
|
||||||
|
.cat.on { background: #FFF3E6; color: #E36B0C; font-weight: 600; }
|
||||||
|
.read-all { font-size: 24rpx; color: #F0710D; }
|
||||||
|
.list { padding: 22rpx 28rpx; }
|
||||||
|
.msg { background: #fff; border-radius: 20rpx; padding: 22rpx 24rpx; margin-bottom: 18rpx; box-shadow: 0 6rpx 20rpx rgba(240,129,31,.06); }
|
||||||
|
.msg.unread { border-left: 6rpx solid #F0710D; }
|
||||||
|
.msg-top { display: flex; align-items: center; }
|
||||||
|
.dot { width: 12rpx; height: 12rpx; border-radius: 50%; background: #F0710D; margin-right: 10rpx; }
|
||||||
|
.title { flex: 1; min-width: 0; font-size: 28rpx; font-weight: 600; color: #1F1F1F; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||||
|
.time { flex-shrink: 0; margin-left: 12rpx; font-size: 20rpx; color: #B5B5B5; }
|
||||||
|
.content { display: block; margin-top: 10rpx; font-size: 24rpx; color: #666; line-height: 34rpx; }
|
||||||
|
.go { display: block; margin-top: 10rpx; font-size: 22rpx; color: #4A7CFF; }
|
||||||
|
.empty { padding: 110rpx 0; text-align: center; font-size: 24rpx; color: #9A9A9A; }
|
||||||
|
</style>
|
||||||
@@ -51,13 +51,13 @@
|
|||||||
<view class="menu-item" @tap="go('/pages/order/list')">
|
<view class="menu-item" @tap="go('/pages/order/list')">
|
||||||
<text class="menu-t">我的订单</text><text class="menu-a">›</text>
|
<text class="menu-t">我的订单</text><text class="menu-a">›</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="menu-item" @tap="toast('地址管理开发中')">
|
<view class="menu-item" @tap="go('/pages/address/list')">
|
||||||
<text class="menu-t">服务地址</text><text class="menu-a">›</text>
|
<text class="menu-t">服务地址</text><text class="menu-a">›</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="menu-item" @tap="toast('优惠券中心开发中')">
|
<view class="menu-item" @tap="go('/pages/coupon/coupon')">
|
||||||
<text class="menu-t">优惠券</text><text class="menu-a">›</text>
|
<text class="menu-t">优惠券</text><text class="menu-a">›</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="menu-item" @tap="toast('消息中心开发中')">
|
<view class="menu-item" @tap="go('/pages/message/message')">
|
||||||
<text class="menu-t">消息中心</text><text class="menu-a">›</text>
|
<text class="menu-t">消息中心</text><text class="menu-a">›</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="menu-item" @tap="callService">
|
<view class="menu-item" @tap="callService">
|
||||||
|
|||||||
251
server/application/admin/view/index/login.html
可执行文件 -> 普通文件
251
server/application/admin/view/index/login.html
可执行文件 -> 普通文件
@@ -4,139 +4,148 @@
|
|||||||
{include file="common/meta" /}
|
{include file="common/meta" /}
|
||||||
|
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
body {
|
* { box-sizing: border-box; }
|
||||||
color: #999;
|
body { margin: 0; color: #333; background: #FFF7F0;
|
||||||
background-color: #f1f4fd;
|
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", Arial, sans-serif; }
|
||||||
background-size: cover;
|
a { color: #E36B0C; }
|
||||||
|
.login-page { min-height: 100vh; display: flex; }
|
||||||
|
|
||||||
|
/* ---------- 左侧品牌区 ---------- */
|
||||||
|
.brand { position: relative; width: 54%; min-height: 100vh; overflow: hidden; color: #fff;
|
||||||
|
padding: 0 72px; display: flex; flex-direction: column; justify-content: center;
|
||||||
|
background: linear-gradient(160deg, #FFB85C 0%, #F98D25 46%, #ED6F0E 100%); }
|
||||||
|
.brand .deco { position: absolute; border-radius: 50%; background: rgba(255,255,255,.13); }
|
||||||
|
.brand .d1 { width: 520px; height: 520px; right: -180px; top: -200px; }
|
||||||
|
.brand .d2 { width: 320px; height: 320px; left: -120px; bottom: -120px; background: rgba(255,255,255,.10); }
|
||||||
|
.brand .d3 { width: 160px; height: 160px; right: 90px; bottom: 90px; background: rgba(255,255,255,.08); }
|
||||||
|
|
||||||
|
.logo-mark { position: relative; width: 64px; height: 64px; border-radius: 18px;
|
||||||
|
background: rgba(255,255,255,.94); color: #EE7412; font-size: 34px; font-weight: 800;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
box-shadow: 0 10px 24px rgba(170,70,10,.22); }
|
||||||
|
.wordmark { position: relative; margin-top: 30px; font-size: 60px; font-weight: 800;
|
||||||
|
letter-spacing: 10px; line-height: 1.1; color: #fff;
|
||||||
|
text-shadow: 0 2px 0 #E9721A, 0 5px 0 #D9630E, 0 10px 24px rgba(150,60,0,.35); }
|
||||||
|
.slogan { position: relative; margin-top: 18px; font-size: 17px;
|
||||||
|
letter-spacing: 4px; color: rgba(255,255,255,.92); }
|
||||||
|
.stats { position: relative; display: flex; margin-top: 54px; }
|
||||||
|
.stat { margin-right: 56px; }
|
||||||
|
.stat b { display: block; font-size: 30px; font-weight: 800; letter-spacing: 1px; }
|
||||||
|
.stat span { display: block; margin-top: 6px; font-size: 13px; color: rgba(255,255,255,.85); letter-spacing: 1px; }
|
||||||
|
.brand-tip { position: relative; margin-top: 46px; font-size: 13px;
|
||||||
|
color: rgba(255,255,255,.82); letter-spacing: 1px; }
|
||||||
|
|
||||||
|
/* ---------- 右侧表单区 ---------- */
|
||||||
|
.panel { flex: 1; display: flex; align-items: center; justify-content: center; padding: 40px 24px; }
|
||||||
|
.login-screen { width: 100%; max-width: 380px; }
|
||||||
|
.login-screen .well { padding: 40px 34px 32px; background: #fff; border: none; border-radius: 20px;
|
||||||
|
box-shadow: 0 18px 48px rgba(226,120,26,.16); }
|
||||||
|
.panel-title { margin: 0 0 6px; font-size: 24px; font-weight: 700; color: #1F1F1F; }
|
||||||
|
.panel-sub { margin: 0 0 26px; font-size: 13px; color: #9A9A9A; }
|
||||||
|
|
||||||
|
#login-form .input-group { display: flex; margin-bottom: 16px; }
|
||||||
|
#login-form .input-group-addon { width: 46px; flex: none; background: #FFF7F0;
|
||||||
|
border: 1px solid #F2E3D6; border-right: none; border-radius: 12px 0 0 12px;
|
||||||
|
color: #E07A2E; display: flex; align-items: center; justify-content: center; }
|
||||||
|
#login-form .form-control { flex: 1; height: 46px; padding: 0 14px; font-size: 14px;
|
||||||
|
border: 1px solid #F2E3D6; border-radius: 0 12px 12px 0; box-shadow: none; }
|
||||||
|
#login-form .input-group .form-control:not(:last-child) { border-radius: 0; }
|
||||||
|
#login-form .form-control:focus { border-color: #F5861F; outline: none; }
|
||||||
|
#login-form .btn-submit { width: 100%; height: 48px; margin-top: 6px; border: none; border-radius: 24px;
|
||||||
|
color: #fff; font-size: 16px; font-weight: 700; letter-spacing: 2px; cursor: pointer;
|
||||||
|
background: linear-gradient(135deg, #FFA93F 0%, #F0710D 100%);
|
||||||
|
box-shadow: 0 10px 22px rgba(240,113,13,.32); }
|
||||||
|
#login-form .btn-submit:hover { filter: brightness(1.04); }
|
||||||
|
#login-form .checkbox { margin: 2px 0 16px; color: #8A8A8A; font-size: 13px; }
|
||||||
|
#errtips { margin-bottom: 14px; padding: 10px 14px; border-radius: 10px;
|
||||||
|
background: #FFF1EF; color: #E8331B; font-size: 13px; }
|
||||||
|
.foot { margin-top: 22px; text-align: center; font-size: 12px; color: #B9B9B9; line-height: 20px; }
|
||||||
|
#profile-img, #profile-name { display: none !important; }
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.login-page { flex-direction: column; }
|
||||||
|
.brand { width: 100%; min-height: auto; padding: 40px 28px 34px; }
|
||||||
|
.wordmark { margin-top: 18px; font-size: 38px; letter-spacing: 6px; }
|
||||||
|
.stats { margin-top: 26px; }
|
||||||
|
.stat { margin-right: 30px; }
|
||||||
|
.stat b { font-size: 22px; }
|
||||||
|
.brand-tip { margin-top: 24px; }
|
||||||
|
.panel { padding: 26px 20px 40px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
|
||||||
color: #444;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.login-screen {
|
|
||||||
max-width: 430px;
|
|
||||||
padding: 0;
|
|
||||||
margin: 100px auto 0 auto;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-screen .well {
|
|
||||||
border-radius: 3px;
|
|
||||||
-webkit-box-shadow: 0 0 30px rgba(0, 0, 0, 0.1);
|
|
||||||
box-shadow: 0 0 30px rgba(0, 0, 0, 0.1);
|
|
||||||
background: rgba(255, 255, 255, 1);
|
|
||||||
border: none;
|
|
||||||
/*overflow: hidden;*/
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.login-screen {
|
|
||||||
padding: 0 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-img-card {
|
|
||||||
width: 100px;
|
|
||||||
height: 100px;
|
|
||||||
display: block;
|
|
||||||
-moz-border-radius: 50%;
|
|
||||||
-webkit-border-radius: 50%;
|
|
||||||
border-radius: 50%;
|
|
||||||
margin: -93px auto 30px;
|
|
||||||
border: 5px solid #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-name-card {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-head {
|
|
||||||
background: #899fe1;
|
|
||||||
border-radius: 3px 3px 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login-form {
|
|
||||||
padding: 40px 30px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 99;
|
|
||||||
}
|
|
||||||
|
|
||||||
#login-form {
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#login-form .input-group {
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#login-form .form-control {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
<!--@formatter:off-->
|
<!--@formatter:off-->
|
||||||
{if $background}
|
{if $background}
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
body{
|
.brand { background-image: url('{$background|htmlentities}'); background-size: cover; background-position: center; }
|
||||||
background-image: url('{$background|htmlentities}');
|
</style>
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{/if}
|
{/if}
|
||||||
<!--@formatter:on-->
|
<!--@formatter:on-->
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="login-page">
|
||||||
<div class="login-wrapper">
|
<!-- 左侧:品牌 + 平台实力 -->
|
||||||
|
<div class="brand">
|
||||||
|
<div class="deco d1"></div>
|
||||||
|
<div class="deco d2"></div>
|
||||||
|
<div class="deco d3"></div>
|
||||||
|
<div class="logo-mark">优</div>
|
||||||
|
<div class="wordmark">优匠优服</div>
|
||||||
|
<div class="slogan">专业师傅 · 上门到家</div>
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat"><b>460万+</b><span>专业师傅</span></div>
|
||||||
|
<div class="stat"><b>99.9%</b><span>区域覆盖到村镇</span></div>
|
||||||
|
<div class="stat"><b>1762万</b><span>成功订单</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="brand-tip">平台认证 · 先行赔付 · 极速上门 · 售后无忧</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧:登录表单 -->
|
||||||
|
<div class="panel">
|
||||||
<div class="login-screen">
|
<div class="login-screen">
|
||||||
<div class="well">
|
<div class="well">
|
||||||
<div class="login-head">
|
<p class="panel-title">平台管理后台</p>
|
||||||
<img src="__CDN__/assets/img/login-head.png" style="width:100%;"/>
|
<p class="panel-sub">请使用管理员账号登录</p>
|
||||||
</div>
|
|
||||||
<div class="login-form">
|
|
||||||
<img id="profile-img" class="profile-img-card" src="__CDN__/assets/img/avatar.png"/>
|
|
||||||
<p id="profile-name" class="profile-name-card"></p>
|
|
||||||
|
|
||||||
<form action="" method="post" id="login-form">
|
<img id="profile-img" class="profile-img-card" src="__CDN__/assets/img/avatar.png"/>
|
||||||
<!--@AdminLoginFormBegin-->
|
<p id="profile-name" class="profile-name-card"></p>
|
||||||
<div id="errtips" class="hide"></div>
|
|
||||||
{:token()}
|
|
||||||
<div class="input-group">
|
|
||||||
<div class="input-group-addon"><span class="glyphicon glyphicon-user" aria-hidden="true"></span></div>
|
|
||||||
<input type="text" class="form-control" id="pd-form-username" placeholder="{:__('Username')}" name="username" autocomplete="off" value="" data-rule="{:__('Username')}:required;username"/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="input-group">
|
<form action="" method="post" id="login-form">
|
||||||
<div class="input-group-addon"><span class="glyphicon glyphicon-lock" aria-hidden="true"></span></div>
|
<!--@AdminLoginFormBegin-->
|
||||||
<input type="password" class="form-control" id="pd-form-password" placeholder="{:__('Password')}" name="password" autocomplete="off" value="" data-rule="{:__('Password')}:required;password"/>
|
<div id="errtips" class="hide"></div>
|
||||||
</div>
|
{:token()}
|
||||||
<!--@CaptchaBegin-->
|
<div class="input-group">
|
||||||
{if $Think.config.fastadmin.login_captcha}
|
<div class="input-group-addon"><span class="glyphicon glyphicon-user" aria-hidden="true"></span></div>
|
||||||
<div class="input-group">
|
<input type="text" class="form-control" id="pd-form-username" placeholder="{:__('Username')}" name="username" autocomplete="off" value="" data-rule="{:__('Username')}:required;username"/>
|
||||||
<div class="input-group-addon"><span class="glyphicon glyphicon-option-horizontal" aria-hidden="true"></span></div>
|
</div>
|
||||||
<input type="text" name="captcha" class="form-control" placeholder="{:__('Captcha')}" data-rule="{:__('Captcha')}:required;length({$Think.config.captcha.length|htmlentities})" autocomplete="off"/>
|
|
||||||
<span class="input-group-addon" style="padding:0;border:none;cursor:pointer;">
|
<div class="input-group">
|
||||||
<img src="{:rtrim('__PUBLIC__', '/')}/index.php?s=/captcha" width="100" height="30" onclick="this.src = '{:rtrim('__PUBLIC__', '/')}/index.php?s=/captcha&r=' + Math.random();"/>
|
<div class="input-group-addon"><span class="glyphicon glyphicon-lock" aria-hidden="true"></span></div>
|
||||||
</span>
|
<input type="password" class="form-control" id="pd-form-password" placeholder="{:__('Password')}" name="password" autocomplete="off" value="" data-rule="{:__('Password')}:required;password"/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
<!--@CaptchaBegin-->
|
||||||
<!--@CaptchaEnd-->
|
{if $Think.config.fastadmin.login_captcha}
|
||||||
{if $keeyloginhours>0}
|
<div class="input-group">
|
||||||
<div class="form-group checkbox">
|
<div class="input-group-addon"><span class="glyphicon glyphicon-option-horizontal" aria-hidden="true"></span></div>
|
||||||
<label class="inline" for="keeplogin" data-toggle="tooltip" title="{:__('The duration of the session is %s hours', $keeyloginhours)}">
|
<input type="text" name="captcha" class="form-control" placeholder="{:__('Captcha')}" data-rule="{:__('Captcha')}:required;length({$Think.config.captcha.length|htmlentities})" autocomplete="off"/>
|
||||||
<input type="checkbox" name="keeplogin" id="keeplogin" value="1"/>
|
<span class="input-group-addon" style="width:106px;border:1px solid #F2E3D6;border-left:none;border-radius:0 12px 12px 0;background:#fff;padding:0;cursor:pointer;">
|
||||||
{:__('Keep login')}
|
<img src="{:rtrim('__PUBLIC__', '/')}/index.php?s=/captcha" width="100" height="30" onclick="this.src = '{:rtrim('__PUBLIC__', '/')}/index.php?s=/captcha&r=' + Math.random();"/>
|
||||||
</label>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="form-group">
|
<!--@CaptchaEnd-->
|
||||||
<button type="submit" class="btn btn-success btn-lg btn-block" style="background:#708eea;">{:__('Sign in')}</button>
|
{if $keeyloginhours>0}
|
||||||
</div>
|
<div class="form-group checkbox">
|
||||||
<!--@AdminLoginFormEnd-->
|
<label class="inline" for="keeplogin" data-toggle="tooltip" title="{:__('The duration of the session is %s hours', $keeyloginhours)}">
|
||||||
</form>
|
<input type="checkbox" name="keeplogin" id="keeplogin" value="1"/>
|
||||||
</div>
|
{:__('Keep login')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<button type="submit" class="btn-submit">{:__('Sign in')}</button>
|
||||||
|
<!--@AdminLoginFormEnd-->
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="foot">优匠优服 · 让手艺人有活干,让家有温度<br/>© {:date('Y')} 优匠优服 平台管理后台</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\api\controller;
|
||||||
|
|
||||||
|
use think\Db;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 优惠券:领券中心 / 我的券 / 领取
|
||||||
|
*/
|
||||||
|
class Coupon extends Base
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 领券中心(可领取的券)
|
||||||
|
*/
|
||||||
|
public function center()
|
||||||
|
{
|
||||||
|
$uid = $this->uid();
|
||||||
|
$now = time();
|
||||||
|
|
||||||
|
$list = Db::name('coupon')
|
||||||
|
->where('status', 'normal')
|
||||||
|
->where(function ($q) use ($now) {
|
||||||
|
$q->where('end_time', 0)->whereOr('end_time', '>', $now);
|
||||||
|
})
|
||||||
|
->order('amount desc,id desc')
|
||||||
|
->select();
|
||||||
|
|
||||||
|
$receivedIds = Db::name('user_coupon')->where('user_id', $uid)->column('coupon_id');
|
||||||
|
|
||||||
|
foreach ($list as &$row) {
|
||||||
|
$row['amount'] = (float)$row['amount'];
|
||||||
|
$row['min_amount'] = (float)$row['min_amount'];
|
||||||
|
$row['discount'] = (float)$row['discount'];
|
||||||
|
$row['total'] = (int)$row['total'];
|
||||||
|
$row['received'] = (int)$row['received'];
|
||||||
|
$row['received_by_me'] = in_array((int)$row['id'], array_map('intval', $receivedIds), true);
|
||||||
|
$row['sold_out'] = $row['total'] > 0 && $row['received'] >= $row['total'];
|
||||||
|
$row['time_text'] = (int)$row['end_time'] > 0 ? date('Y.m.d', (int)$row['end_time']) . ' 到期' : '长期有效';
|
||||||
|
$row['value_text'] = $row['type'] === 'cash'
|
||||||
|
? ('¥' . (float)$row['amount'])
|
||||||
|
: (rtrim(rtrim(number_format((float)$row['discount'] * 10, 1, '.', ''), '0'), '.') . ' 折');
|
||||||
|
$row['rule_text'] = $row['min_amount'] > 0 ? ('满 ¥' . (float)$row['min_amount'] . ' 可用') : '无门槛';
|
||||||
|
}
|
||||||
|
unset($row);
|
||||||
|
|
||||||
|
$this->success('', ['list' => $list]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我的优惠券
|
||||||
|
* @ApiParams (name="status", type="string", required=false, description="unused|used|expired")
|
||||||
|
*/
|
||||||
|
public function my()
|
||||||
|
{
|
||||||
|
$status = $this->request->param('status', 'unused');
|
||||||
|
if (!in_array($status, ['unused', 'used', 'expired'], true)) {
|
||||||
|
$status = 'unused';
|
||||||
|
}
|
||||||
|
$uid = $this->uid();
|
||||||
|
$now = time();
|
||||||
|
|
||||||
|
$list = Db::name('user_coupon')->alias('uc')
|
||||||
|
->join('coupon c', 'c.id = uc.coupon_id', 'LEFT')
|
||||||
|
->field('uc.id,uc.status,uc.use_time,uc.createtime,c.name,c.type,c.amount,c.discount,c.min_amount,c.end_time')
|
||||||
|
->where('uc.user_id', $uid)
|
||||||
|
->where('uc.status', $status)
|
||||||
|
->order('uc.id desc')
|
||||||
|
->select();
|
||||||
|
|
||||||
|
$counts = ['unused' => 0, 'used' => 0, 'expired' => 0];
|
||||||
|
$all = Db::name('user_coupon')->where('user_id', $uid)->field('status,count(*) as c')->group('status')->select();
|
||||||
|
foreach ($all as $r) {
|
||||||
|
$counts[$r['status']] = (int)$r['c'];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($list as &$row) {
|
||||||
|
$row['amount'] = (float)$row['amount'];
|
||||||
|
$row['min_amount'] = (float)$row['min_amount'];
|
||||||
|
$row['discount'] = (float)$row['discount'];
|
||||||
|
$row['value_text'] = $row['type'] === 'cash'
|
||||||
|
? ('¥' . (float)$row['amount'])
|
||||||
|
: (rtrim(rtrim(number_format((float)$row['discount'] * 10, 1, '.', ''), '0'), '.') . ' 折');
|
||||||
|
$row['rule_text'] = $row['min_amount'] > 0 ? ('满 ¥' . (float)$row['min_amount'] . ' 可用') : '无门槛';
|
||||||
|
$row['time_text'] = (int)$row['end_time'] > 0 ? ('有效期至 ' . date('Y.m.d', (int)$row['end_time'])) : '长期有效';
|
||||||
|
$row['expired'] = (int)$row['end_time'] > 0 && (int)$row['end_time'] < $now;
|
||||||
|
}
|
||||||
|
unset($row);
|
||||||
|
|
||||||
|
$this->success('', ['list' => $list, 'counts' => $counts]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 领取优惠券
|
||||||
|
* @ApiMethod (POST)
|
||||||
|
*/
|
||||||
|
public function receive()
|
||||||
|
{
|
||||||
|
$couponId = (int)$this->request->post('coupon_id');
|
||||||
|
$coupon = Db::name('coupon')->where('id', $couponId)->where('status', 'normal')->find();
|
||||||
|
if (!$coupon) {
|
||||||
|
$this->error('优惠券不存在或已下架');
|
||||||
|
}
|
||||||
|
if ((int)$coupon['end_time'] > 0 && (int)$coupon['end_time'] < time()) {
|
||||||
|
$this->error('优惠券已过期');
|
||||||
|
}
|
||||||
|
if ((int)$coupon['total'] > 0 && (int)$coupon['received'] >= (int)$coupon['total']) {
|
||||||
|
$this->error('优惠券已被领完');
|
||||||
|
}
|
||||||
|
|
||||||
|
$has = Db::name('user_coupon')->where('user_id', $this->uid())->where('coupon_id', $couponId)->find();
|
||||||
|
if ($has) {
|
||||||
|
$this->error('您已领取过该优惠券');
|
||||||
|
}
|
||||||
|
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
Db::name('user_coupon')->insert([
|
||||||
|
'user_id' => $this->uid(),
|
||||||
|
'coupon_id' => $couponId,
|
||||||
|
'status' => 'unused',
|
||||||
|
'createtime' => time(),
|
||||||
|
]);
|
||||||
|
Db::name('coupon')->where('id', $couponId)->setInc('received');
|
||||||
|
Db::commit();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Db::rollback();
|
||||||
|
$this->error('领取失败:' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->notify('user', $this->uid(), '优惠券领取成功', '「' . $coupon['name'] . '」已放入我的优惠券', 'system', 0);
|
||||||
|
$this->success('领取成功');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\api\controller;
|
||||||
|
|
||||||
|
use think\Db;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息中心(用户 / 师傅共用)
|
||||||
|
*/
|
||||||
|
class Message extends Base
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 消息列表
|
||||||
|
* @ApiParams (name="type", type="string", required=false, description="user|master,默认 user")
|
||||||
|
* @ApiParams (name="category", type="string", required=false, description="all|order|system|audit|remind")
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$type = $this->request->param('type', 'user');
|
||||||
|
$type = $type === 'master' ? 'master' : 'user';
|
||||||
|
$category = $this->request->param('category', 'all');
|
||||||
|
$page = max(1, (int)$this->request->param('page', 1));
|
||||||
|
$limit = min(50, max(1, (int)$this->request->param('limit', 20)));
|
||||||
|
|
||||||
|
$receiveId = $this->receiveId($type);
|
||||||
|
|
||||||
|
$build = function () use ($type, $receiveId, $category) {
|
||||||
|
$q = Db::name('message')->where('receive_type', $type)->where('receive_id', $receiveId);
|
||||||
|
if ($category !== 'all' && $category !== '') {
|
||||||
|
$q->where('type', $category);
|
||||||
|
}
|
||||||
|
return $q;
|
||||||
|
};
|
||||||
|
|
||||||
|
$total = $build()->count();
|
||||||
|
$list = $build()->order('id desc')->page($page, $limit)->select();
|
||||||
|
|
||||||
|
foreach ($list as &$row) {
|
||||||
|
$row['time_text'] = date('m-d H:i', (int)$row['createtime']);
|
||||||
|
$row['is_read'] = (int)$row['is_read'];
|
||||||
|
}
|
||||||
|
unset($row);
|
||||||
|
|
||||||
|
$this->success('', [
|
||||||
|
'list' => $list,
|
||||||
|
'total' => (int)$total,
|
||||||
|
'unread' => $this->unreadCount($type, $receiveId),
|
||||||
|
'page' => $page,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记单条已读
|
||||||
|
* @ApiMethod (POST)
|
||||||
|
*/
|
||||||
|
public function read()
|
||||||
|
{
|
||||||
|
$id = (int)$this->request->post('id');
|
||||||
|
$type = $this->request->post('type', 'user') === 'master' ? 'master' : 'user';
|
||||||
|
$receiveId = $this->receiveId($type);
|
||||||
|
|
||||||
|
$row = Db::name('message')->where('id', $id)->where('receive_type', $type)->where('receive_id', $receiveId)->find();
|
||||||
|
if (!$row) {
|
||||||
|
$this->error('消息不存在');
|
||||||
|
}
|
||||||
|
Db::name('message')->where('id', $id)->update(['is_read' => 1]);
|
||||||
|
$this->success('已读');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全部标记已读
|
||||||
|
* @ApiMethod (POST)
|
||||||
|
*/
|
||||||
|
public function readAll()
|
||||||
|
{
|
||||||
|
$type = $this->request->post('type', 'user') === 'master' ? 'master' : 'user';
|
||||||
|
$receiveId = $this->receiveId($type);
|
||||||
|
$affected = Db::name('message')
|
||||||
|
->where('receive_type', $type)->where('receive_id', $receiveId)->where('is_read', 0)
|
||||||
|
->update(['is_read' => 1]);
|
||||||
|
$this->success('已全部标记为已读', ['count' => (int)$affected]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 未读数(供 Tab 角标)
|
||||||
|
*/
|
||||||
|
public function unread()
|
||||||
|
{
|
||||||
|
$type = $this->request->param('type', 'user') === 'master' ? 'master' : 'user';
|
||||||
|
$this->success('', ['unread' => $this->unreadCount($type, $this->receiveId($type))]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息归属ID:用户=uid,师傅=master.id
|
||||||
|
*/
|
||||||
|
protected function receiveId($type)
|
||||||
|
{
|
||||||
|
if ($type === 'master') {
|
||||||
|
$master = $this->masterInfo();
|
||||||
|
return $master ? (int)$master['id'] : 0;
|
||||||
|
}
|
||||||
|
return $this->uid();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function unreadCount($type, $receiveId)
|
||||||
|
{
|
||||||
|
if (!$receiveId) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return (int)Db::name('message')
|
||||||
|
->where('receive_type', $type)->where('receive_id', $receiveId)->where('is_read', 0)
|
||||||
|
->count();
|
||||||
|
}
|
||||||
|
}
|
||||||
85
tests/phase4_coupon_message.js
普通文件
85
tests/phase4_coupon_message.js
普通文件
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Phase 4 验证:优惠券 + 消息中心(对应「我的中心」新接入的模块)
|
||||||
|
* 运行:node tests/phase4_coupon_message.js
|
||||||
|
*/
|
||||||
|
const BASE = process.env.API_BASE || 'https://yjyf.gzyakj.com.cn/api';
|
||||||
|
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
const failures = [];
|
||||||
|
const ok = (m, e) => { pass++; console.log(' ✅ ' + m + (e ? ' → ' + e : '')); };
|
||||||
|
const bad = (m, e) => { fail++; failures.push(m + ' :: ' + e); console.log(' ❌ ' + m + ' → ' + e); };
|
||||||
|
const chk = (c, m, e) => (c ? ok(m, e) : bad(m, e || '断言失败'));
|
||||||
|
const sec = (t) => console.log('\n▌' + t);
|
||||||
|
|
||||||
|
async function api(path, opt) {
|
||||||
|
const o = opt || {};
|
||||||
|
const headers = {};
|
||||||
|
if (o.token) headers.token = o.token;
|
||||||
|
let body;
|
||||||
|
if (o.data) {
|
||||||
|
headers['content-type'] = 'application/x-www-form-urlencoded';
|
||||||
|
body = new URLSearchParams(o.data).toString();
|
||||||
|
}
|
||||||
|
const res = await fetch(BASE + path, { method: o.method || 'GET', headers, body });
|
||||||
|
const text = await res.text();
|
||||||
|
try { return JSON.parse(text); } catch (e) { throw new Error('非 JSON: ' + text.slice(0, 120)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const mobile = '136' + String(Date.now()).slice(-8);
|
||||||
|
|
||||||
|
console.log('═══════════════════════════════════════════════════');
|
||||||
|
console.log(' Phase 4 验证:优惠券 + 消息中心');
|
||||||
|
console.log('═══════════════════════════════════════════════════');
|
||||||
|
|
||||||
|
const login = await api('/auth/login', { method: 'POST', data: { mobile: mobile, code: '123456' } });
|
||||||
|
const token = login.data.token;
|
||||||
|
chk(login.code === 1, '账号登录');
|
||||||
|
|
||||||
|
sec('优惠券');
|
||||||
|
let r = await api('/coupon/center', { token: token });
|
||||||
|
chk(r.code === 1 && r.data.list.length >= 1, '领券中心返回可领券', '共 ' + (r.data.list || []).length + ' 张');
|
||||||
|
const c0 = r.data.list[0];
|
||||||
|
chk(!!c0.value_text && !!c0.rule_text, '券面值/使用门槛文案就绪', c0.value_text + ' / ' + c0.rule_text);
|
||||||
|
chk(c0.received_by_me === false, '未领取状态正确');
|
||||||
|
|
||||||
|
r = await api('/coupon/receive', { method: 'POST', token: token, data: { coupon_id: c0.id } });
|
||||||
|
chk(r.code === 1, '领取优惠券成功', r.msg);
|
||||||
|
|
||||||
|
r = await api('/coupon/receive', { method: 'POST', token: token, data: { coupon_id: c0.id } });
|
||||||
|
chk(r.code !== 1, '重复领取被拦截', r.msg);
|
||||||
|
|
||||||
|
r = await api('/coupon/my?status=unused', { token: token });
|
||||||
|
chk(r.code === 1 && r.data.list.length === 1, '我的券-未使用有 1 张', JSON.stringify(r.data.counts));
|
||||||
|
|
||||||
|
r = await api('/coupon/center', { token: token });
|
||||||
|
const c1 = (r.data.list || []).find(c => Number(c.id) === Number(c0.id));
|
||||||
|
chk(c1 && c1.received_by_me === true, '领券中心回显「已领取」');
|
||||||
|
|
||||||
|
sec('消息中心');
|
||||||
|
r = await api('/message/index', { token: token });
|
||||||
|
chk(r.code === 1 && r.data.list.length >= 1, '领券后自动生成消息', '共 ' + r.data.total + ' 条');
|
||||||
|
chk(r.data.unread >= 1, '未读数统计正确', 'unread=' + r.data.unread);
|
||||||
|
|
||||||
|
const msg = r.data.list[0];
|
||||||
|
r = await api('/message/read', { method: 'POST', token: token, data: { id: msg.id } });
|
||||||
|
chk(r.code === 1, '单条标记已读');
|
||||||
|
|
||||||
|
r = await api('/message/unread', { token: token });
|
||||||
|
chk(r.code === 1 && r.data.unread === 0, '未读数归零', 'unread=' + r.data.unread);
|
||||||
|
|
||||||
|
r = await api('/message/index?category=order', { token: token });
|
||||||
|
chk(r.code === 1, '按分类筛选可用', '订单类 ' + r.data.total + ' 条');
|
||||||
|
|
||||||
|
r = await api('/message/readAll', { method: 'POST', token: token, data: {} });
|
||||||
|
chk(r.code === 1, '全部标记已读接口可用', '影响 ' + r.data.count + ' 条');
|
||||||
|
|
||||||
|
r = await api('/message/index', { token: '', silent: true });
|
||||||
|
chk(r.code === 401 || r.code !== 1, '未登录访问消息被拦截', r.msg);
|
||||||
|
|
||||||
|
console.log('\n═══════════════════════════════════════════════════');
|
||||||
|
console.log(` 结果:通过 ${pass} 项 / 失败 ${fail} 项`);
|
||||||
|
if (fail) { console.log(' 失败明细:'); failures.forEach(f => console.log(' - ' + f)); process.exitCode = 1; }
|
||||||
|
else console.log(' 🎉 优惠券与消息中心链路通过');
|
||||||
|
console.log('═══════════════════════════════════════════════════');
|
||||||
|
})().catch(e => { console.error('\n💥 中断: ' + e.message); process.exitCode = 1; });
|
||||||
11
utils/api.js
11
utils/api.js
@@ -56,6 +56,17 @@ export default {
|
|||||||
reviewCreate: (data) => post('/review/create', data),
|
reviewCreate: (data) => post('/review/create', data),
|
||||||
reviewList: (masterId) => get('/review/masterList', { master_id: masterId }),
|
reviewList: (masterId) => get('/review/masterList', { master_id: masterId }),
|
||||||
|
|
||||||
|
/* ---------- 优惠券 ---------- */
|
||||||
|
couponCenter: () => get('/coupon/center'),
|
||||||
|
couponMy: (data) => get('/coupon/my', data),
|
||||||
|
couponReceive: (data) => post('/coupon/receive', data),
|
||||||
|
|
||||||
|
/* ---------- 消息中心 ---------- */
|
||||||
|
messageList: (data) => get('/message/index', data),
|
||||||
|
messageRead: (data) => post('/message/read', data),
|
||||||
|
messageReadAll: (data) => post('/message/readAll', data),
|
||||||
|
messageUnread: (data) => get('/message/unread', data),
|
||||||
|
|
||||||
/* ---------- 商家端 ---------- */
|
/* ---------- 商家端 ---------- */
|
||||||
merchantApply: (data) => post('/merchant/apply', data),
|
merchantApply: (data) => post('/merchant/apply', data),
|
||||||
merchantInfo: () => get('/merchant/info'),
|
merchantInfo: () => get('/merchant/info'),
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户