数据库文档编写,开发规范文档,数据库接入

This commit is contained in:
comlibmb
2026-02-02 18:09:30 +08:00
parent 19970db288
commit 21149dd3fe
36 changed files with 3245 additions and 89 deletions

View File

@@ -1,5 +1,5 @@
<template>
<view class="page" @click="closeMoreMenu">
<view class="page" @click.self="closeMoreMenu">
<!-- 固定顶部导航栏 -->
<AnalyticsTopBar
:title="'优惠券效果分析'"
@@ -27,19 +27,34 @@
<view class="main-content">
<view class="container">
<!-- 时间维度筛选 -->
<!-- 时间维度筛选(快捷 + 自定义) -->
<view class="tabs">
<view
v-for="p in timePeriods"
:key="p.value"
class="tab"
:class="{ active: selectedPeriod === p.value }"
:class="{ active: selectedPeriod === p.value && !customRangeEnabled }"
@click="selectPeriod(p.value)"
>
{{ p.label }}
</view>
<view
class="tab"
:class="{ active: customRangeEnabled }"
@click="toggleCustomRange"
>
自定义
</view>
</view>
<AnalyticsDateRangePicker
v-if="customRangeEnabled"
:initialStartDate="selectedStartDate"
:initialEndDate="selectedEndDate"
@apply="onDateRangeApply"
@clear="onDateRangeClear"
/>
<!-- KPI 指标卡片 -->
<view class="kpi-grid">
<view class="kpi-card">
@@ -113,6 +128,7 @@ import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import AnalyticsDateRangePicker from '@/components/analytics/AnalyticsDateRangePicker.uvue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
import { fetchCouponAnalysis } from '@/services/analytics/couponAnalysisService.uts'
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
@@ -121,6 +137,11 @@ import type { TimePeriod } from '@/types/analytics/common.uts'
const lastUpdateTime = ref('')
const selectedPeriod = ref('7d')
const customRangeEnabled = ref(false)
const selectedStartDate = ref('')
const selectedEndDate = ref('')
const showMoreMenu = ref(false)
const showSidebarMenu = ref(false)
const currentPath = ref('/pages/mall/analytics/coupon-analysis')
@@ -165,7 +186,11 @@ onLoad(() => {
async function loadCouponData() {
try {
const data = await fetchCouponAnalysis(selectedPeriod.value)
const range = selectedStartDate.value && selectedEndDate.value
? { start: selectedStartDate.value, end: selectedEndDate.value }
: null
const data = await fetchCouponAnalysis(selectedPeriod.value, range)
const overviewRow = data.overviewRow
const typeList = data.typeList
@@ -227,6 +252,27 @@ async function loadCouponData() {
function selectPeriod(p: string) {
selectedPeriod.value = p
customRangeEnabled.value = false
selectedStartDate.value = ''
selectedEndDate.value = ''
loadCouponData()
}
function toggleCustomRange() {
customRangeEnabled.value = !customRangeEnabled.value
}
function onDateRangeApply(range: { start: string; end: string }) {
selectedStartDate.value = range.start
selectedEndDate.value = range.end
customRangeEnabled.value = true
loadCouponData()
}
function onDateRangeClear() {
selectedStartDate.value = ''
selectedEndDate.value = ''
customRangeEnabled.value = false
loadCouponData()
}

View File

@@ -165,6 +165,7 @@ import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uv
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import { goToLogin as goToLoginPage } from '@/utils/utils.uts'
import { getUserIdOrNull } from '@/services/analytics/auth.uts'
import { ensureAnalyticsLogin } from '@/services/analytics/authGuard.uts'
import { listCustomReports, createCustomReport, updateCustomReport, deleteCustomReport } from '@/services/analytics/customReportService.uts'
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
@@ -225,6 +226,7 @@ const chartTypes = ref<Array<ChartType>>([
onLoad(() => {
currentPath.value = '/pages/mall/analytics/custom-report'
if (!ensureAnalyticsLogin({ toastTitle: '请先登录后使用自定义报表' })) return
loadReports()
})
@@ -439,17 +441,27 @@ async function saveReport() {
}
uni.hideLoading()
// 检查 newReportId 是否有效,无效则认为创建失败
if (newReportId == null || newReportId.length === 0) {
uni.showToast({
title: '创建失败未返回报表ID',
icon: 'none',
duration: 3000
})
return
}
uni.showToast({ title: '保存成功', icon: 'success' })
closeModal()
loadReports()
if (newReportId.length > 0) {
setTimeout(() => {
uni.navigateTo({
url: `/pages/mall/analytics/report-detail?reportId=${newReportId}`
})
}, 400)
}
setTimeout(() => {
uni.navigateTo({
url: `/pages/mall/analytics/report-detail?reportId=${newReportId}`
})
}, 400)
} catch (e: any) {
uni.hideLoading()
console.error('saveReport exception:', e)
@@ -493,6 +505,10 @@ function closeMoreMenu() {
showMoreMenu.value = false
}
function goToLogin() {
ensureAnalyticsLogin({ toastTitle: '请先登录后使用自定义报表' })
}
function handleSearch() {
uni.showToast({ title: '搜索', icon: 'none' })
}

View File

@@ -27,19 +27,34 @@
<view class="main-content">
<view class="container">
<!-- 时间维度筛选 -->
<!-- 时间维度筛选(快捷 + 自定义) -->
<view class="tabs">
<view
v-for="p in timePeriods"
:key="p.value"
class="tab"
:class="{ active: selectedPeriod === p.value }"
:class="{ active: selectedPeriod === p.value && !customRangeEnabled }"
@click="selectPeriod(p.value)"
>
{{ p.label }}
</view>
<view
class="tab"
:class="{ active: customRangeEnabled }"
@click="toggleCustomRange"
>
自定义
</view>
</view>
<AnalyticsDateRangePicker
v-if="customRangeEnabled"
:initialStartDate="selectedStartDate"
:initialEndDate="selectedEndDate"
@apply="onDateRangeApply"
@clear="onDateRangeClear"
/>
<!-- KPI 指标卡片 -->
<view class="kpi-grid">
<view class="kpi-card">
@@ -120,6 +135,7 @@ import { onLoad } from '@dcloudio/uni-app'
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import AnalyticsDateRangePicker from '@/components/analytics/AnalyticsDateRangePicker.uvue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
import { fetchDeliveryAnalysis } from '@/services/analytics/deliveryAnalysisService.uts'
@@ -130,6 +146,11 @@ import type { DeliveryData, DriverRank } from '@/types/analytics/delivery.uts'
const lastUpdateTime = ref('')
const selectedPeriod = ref('7d')
const customRangeEnabled = ref(false)
const selectedStartDate = ref('')
const selectedEndDate = ref('')
const showMoreMenu = ref(false)
const showSidebarMenu = ref(false)
const currentPath = ref('/pages/mall/analytics/delivery-analysis')
@@ -171,7 +192,11 @@ onLoad(() => {
async function loadDeliveryData() {
try {
const data: any = await fetchDeliveryAnalysis(selectedPeriod.value)
const range = selectedStartDate.value && selectedEndDate.value
? { start: selectedStartDate.value, end: selectedEndDate.value }
: null
const data: any = await fetchDeliveryAnalysis(selectedPeriod.value, range)
const trendList = data.trendList
const topList = data.topList
@@ -263,6 +288,27 @@ async function loadDeliveryData() {
function selectPeriod(p: string) {
selectedPeriod.value = p
customRangeEnabled.value = false
selectedStartDate.value = ''
selectedEndDate.value = ''
loadDeliveryData()
}
function toggleCustomRange() {
customRangeEnabled.value = !customRangeEnabled.value
}
function onDateRangeApply(range: { start: string; end: string }) {
selectedStartDate.value = range.start
selectedEndDate.value = range.end
customRangeEnabled.value = true
loadDeliveryData()
}
function onDateRangeClear() {
selectedStartDate.value = ''
selectedEndDate.value = ''
customRangeEnabled.value = false
loadDeliveryData()
}

View File

@@ -1,5 +1,5 @@
<template>
<view class="page" @click="closeMoreMenu">
<view class="page" @click.self="closeMoreMenu">
<!-- 固定顶部导航栏 -->
<AnalyticsTopBar
:title="'数据分析中心'"
@@ -275,6 +275,8 @@
<script setup lang="uts">
import { computed, reactive, ref, watch } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { ensureAnalyticsLogin } from '@/services/analytics/authGuard.uts'
import AnalyticsComboChart from '@/components/analytics/AnalyticsComboChart.uvue'
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
@@ -684,6 +686,12 @@ async function initDashboard() {
async function selectPeriod(p: string) {
selectedPeriod.value = p
// 切换到快捷时间段时,退出自定义范围
customRangeEnabled.value = false
selectedStartDate.value = ''
selectedEndDate.value = ''
loading.value = true
try {
await Promise.all([loadTrend(), loadUserSegments(), loadTrafficSources(), loadTopProducts(), loadTopMerchants()])
@@ -852,7 +860,8 @@ function exportReport() {
}
onLoad(() => {
initDashboard()
if (!ensureAnalyticsLogin({ toastTitle: '请先登录后查看数据分析' })) return
initDashboard()
})
onUnload(() => {

View File

@@ -1,5 +1,5 @@
<template>
<view class="page" @click="closeMoreMenu">
<view class="page" @click.self="closeMoreMenu">
<!-- 固定顶部导航栏 -->
<AnalyticsTopBar
:title="'市场趋势'"
@@ -27,19 +27,34 @@
<view class="main-content">
<view class="container">
<!-- 时间维度筛选 -->
<!-- 时间维度筛选(快捷 + 自定义) -->
<view class="tabs">
<view
v-for="p in timePeriods"
:key="p.value"
class="tab"
:class="{ active: selectedPeriod === p.value }"
:class="{ active: selectedPeriod === p.value && !customRangeEnabled }"
@click="selectPeriod(p.value)"
>
{{ p.label }}
</view>
<view
class="tab"
:class="{ active: customRangeEnabled }"
@click="toggleCustomRange"
>
自定义
</view>
</view>
<AnalyticsDateRangePicker
v-if="customRangeEnabled"
:initialStartDate="selectedStartDate"
:initialEndDate="selectedEndDate"
@apply="onDateRangeApply"
@clear="onDateRangeClear"
/>
<!-- 市场整体趋势 -->
<view class="card card-full">
<view class="card-head">
@@ -101,6 +116,7 @@ import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uv
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
import AnalyticsDateRangePicker from '@/components/analytics/AnalyticsDateRangePicker.uvue'
import { fetchMarketTrends } from '@/services/analytics/marketTrendsService.uts'
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
@@ -109,6 +125,11 @@ import type { MarketTrendsResponse } from '@/types/analytics/market.uts'
const lastUpdateTime = ref('')
const selectedPeriod = ref('7d')
const customRangeEnabled = ref(false)
const selectedStartDate = ref('')
const selectedEndDate = ref('')
const showMoreMenu = ref(false)
const showSidebarMenu = ref(false)
const currentPath = ref('/pages/mall/analytics/market-trends')
@@ -149,7 +170,11 @@ onShow(() => {
async function loadMarketData() {
try {
const data = (await fetchMarketTrends(selectedPeriod.value)) as MarketTrendsResponse
const range = selectedStartDate.value && selectedEndDate.value
? { start: selectedStartDate.value, end: selectedEndDate.value }
: null
const data = (await fetchMarketTrends(selectedPeriod.value, range)) as MarketTrendsResponse
_marketTrendRows.value = data.trendRows
_industryRows.value = data.categoryRows
@@ -169,6 +194,9 @@ async function loadMarketData() {
function selectPeriod(p: string) {
selectedPeriod.value = p
customRangeEnabled.value = false
selectedStartDate.value = ''
selectedEndDate.value = ''
loadMarketData()
}
@@ -177,6 +205,24 @@ function refreshData() {
uni.showToast({ title: '已刷新', icon: 'success' })
}
function toggleCustomRange() {
customRangeEnabled.value = !customRangeEnabled.value
}
function onDateRangeApply(range: { start: string; end: string }) {
selectedStartDate.value = range.start
selectedEndDate.value = range.end
customRangeEnabled.value = true
loadMarketData()
}
function onDateRangeClear() {
selectedStartDate.value = ''
selectedEndDate.value = ''
customRangeEnabled.value = false
loadMarketData()
}
function exportReport() {
uni.showActionSheet({
itemList: ['导出Excel', '导出PDF', '导出图片'],

View File

@@ -1,5 +1,5 @@
<template>
<view class="page" @click="closeMoreMenu">
<view class="page" @click.self="closeMoreMenu">
<!-- 固定顶部导航栏 -->
<AnalyticsTopBar
:title="'商品洞察'"
@@ -27,19 +27,34 @@
<view class="main-content">
<view class="container">
<!-- 时间维度筛选 -->
<!-- 时间维度筛选(快捷 + 自定义) -->
<view class="tabs">
<view
v-for="p in timePeriods"
:key="p.value"
class="tab"
:class="{ active: selectedPeriod === p.value }"
:class="{ active: selectedPeriod === p.value && !customRangeEnabled }"
@click="selectPeriod(p.value)"
>
{{ p.label }}
</view>
<view
class="tab"
:class="{ active: customRangeEnabled }"
@click="toggleCustomRange"
>
自定义
</view>
</view>
<AnalyticsDateRangePicker
v-if="customRangeEnabled"
:initialStartDate="selectedStartDate"
:initialEndDate="selectedEndDate"
@apply="onDateRangeApply"
@clear="onDateRangeClear"
/>
<!-- KPI 指标卡片 -->
<view class="kpi-grid">
<view class="kpi-card">
@@ -173,6 +188,7 @@ import { onLoad } from '@dcloudio/uni-app'
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import AnalyticsDateRangePicker from '@/components/analytics/AnalyticsDateRangePicker.uvue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
import { fetchProductOverview, fetchTopProducts, fetchProductTrend, fetchCategorySales, fetchStockInsights, fetchPriceTrend, fetchReviewInsights } from '@/services/analytics/productInsightsService.uts'
import { mapAnalyticsError } from '@/services/analytics/errorMapper.uts'
@@ -182,6 +198,11 @@ import type { ProductData, ProductRank } from '@/types/analytics/product.uts'
const lastUpdateTime = ref('')
const selectedPeriod = ref('7d')
const customRangeEnabled = ref(false)
const selectedStartDate = ref('')
const selectedEndDate = ref('')
const showMoreMenu = ref(false)
const showSidebarMenu = ref(false)
const currentPath = ref('/pages/mall/analytics/product-insights')
@@ -251,7 +272,11 @@ async function loadSelectedProductTrend() {
return
}
const trend = await fetchProductTrend(selectedPeriod.value, selectedProductId.value)
const range = selectedStartDate.value && selectedEndDate.value
? { start: selectedStartDate.value, end: selectedEndDate.value }
: null
const trend = await fetchProductTrend(selectedPeriod.value, selectedProductId.value, range)
const rows: Array<any> = trend as any
const x: Array<string> = []
@@ -367,12 +392,16 @@ async function loadProductData() {
try {
updateTime()
const range = selectedStartDate.value && selectedEndDate.value
? { start: selectedStartDate.value, end: selectedEndDate.value }
: null
const [overview, topList, catRows, stockRows, _priceRows, reviewRows] = await Promise.all([
fetchProductOverview(selectedPeriod.value),
fetchTopProducts(selectedPeriod.value, 10),
fetchCategorySales(selectedPeriod.value),
fetchProductOverview(selectedPeriod.value, range),
fetchTopProducts(selectedPeriod.value, 10, range),
fetchCategorySales(selectedPeriod.value, range),
fetchStockInsights(selectedPeriod.value),
fetchPriceTrend(selectedPeriod.value),
fetchPriceTrend(selectedPeriod.value, range),
fetchReviewInsights()
])
@@ -415,6 +444,27 @@ async function loadProductData() {
function selectPeriod(p: string) {
selectedPeriod.value = p
customRangeEnabled.value = false
selectedStartDate.value = ''
selectedEndDate.value = ''
loadProductData()
}
function toggleCustomRange() {
customRangeEnabled.value = !customRangeEnabled.value
}
function onDateRangeApply(range: { start: string; end: string }) {
selectedStartDate.value = range.start
selectedEndDate.value = range.end
customRangeEnabled.value = true
loadProductData()
}
function onDateRangeClear() {
selectedStartDate.value = ''
selectedEndDate.value = ''
customRangeEnabled.value = false
loadProductData()
}

View File

@@ -1,5 +1,5 @@
<template>
<view class="page" @click="closeMoreMenu">
<view class="page" @click.self="closeMoreMenu">
<!-- 固定顶部导航栏 -->
<AnalyticsTopBar
:title="'销售报表'"
@@ -277,6 +277,9 @@ async function loadSalesData() {
function selectPeriod(p: string) {
selectedPeriod.value = p
customRangeEnabled.value = false
selectedStartDate.value = ''
selectedEndDate.value = ''
loadSalesData()
}

View File

@@ -186,6 +186,8 @@ $$;
-- 4) 渠道来源(按注册来源,统计周期内新增用户来源)
-- 兼容性说明:部分环境的 ak_users 可能不存在 registration_source 字段。
-- 为避免 RPC 报错导致首页整体加载失败,这里做“字段存在则分组统计,不存在则全部归为未知”的兼容。
CREATE OR REPLACE FUNCTION public.rpc_analytics_traffic_sources(
p_start_date DATE,
p_end_date DATE
@@ -194,13 +196,37 @@ RETURNS TABLE (
name TEXT,
value BIGINT
)
LANGUAGE sql
AS $$
SELECT
COALESCE(registration_source, '未知') AS name,
COUNT(id)::BIGINT AS value
FROM public.ak_users
WHERE created_at::DATE BETWEEN p_start_date AND p_end_date
GROUP BY name
ORDER BY value DESC;
$$;
LANGUAGE plpgsql
AS $
DECLARE
has_registration_source BOOLEAN := FALSE;
BEGIN
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'ak_users'
AND column_name = 'registration_source'
) INTO has_registration_source;
IF has_registration_source THEN
RETURN QUERY
EXECUTE '
SELECT
COALESCE(registration_source, ''未知'') AS name,
COUNT(id)::BIGINT AS value
FROM public.ak_users
WHERE created_at::DATE BETWEEN $1 AND $2
GROUP BY name
ORDER BY value DESC
'
USING p_start_date, p_end_date;
ELSE
RETURN QUERY
SELECT '未知'::TEXT AS name,
COUNT(id)::BIGINT AS value
FROM public.ak_users
WHERE created_at::DATE BETWEEN p_start_date AND p_end_date;
END IF;
END;
$;

View File

@@ -1,5 +1,5 @@
<template>
<view class="page" @click="closeMoreMenu">
<view class="page" @click.self="closeMoreMenu">
<!-- 固定顶部导航栏 -->
<AnalyticsTopBar
:title="'用户分析'"
@@ -36,14 +36,29 @@
v-for="p in timePeriods"
:key="p.value"
class="tab"
:class="{ active: selectedPeriod === p.value }"
:class="{ active: selectedPeriod === p.value && !customRangeEnabled }"
@click="selectPeriod(p.value)"
>
{{ p.label }}
</view>
<view
class="tab"
:class="{ active: customRangeEnabled }"
@click="toggleCustomRange"
>
自定义
</view>
</view>
</view>
<AnalyticsDateRangePicker
v-if="customRangeEnabled"
:initialStartDate="selectedStartDate"
:initialEndDate="selectedEndDate"
@apply="onDateRangeApply"
@clear="onDateRangeClear"
/>
<view class="filter-hint">
<text class="filter-hint-text">渠道/终端/会员/新老:待接入数据后开放</text>
</view>
@@ -210,6 +225,7 @@
<script setup lang="uts">
import AnalyticsSidebarMenu from '@/components/analytics/AnalyticsSidebarMenu.uvue'
import AnalyticsTopBar from '@/components/analytics/AnalyticsTopBar.uvue'
import AnalyticsDateRangePicker from '@/components/analytics/AnalyticsDateRangePicker.uvue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
import supa, { ensureSupabaseReady } from '@/components/supadb/aksupainstance.uts'
import { getUserIdOrNull } from '@/services/analytics/auth.uts'
@@ -222,6 +238,11 @@ import type { UserData, FunnelStep } from '@/types/analytics/user.uts'
const lastUpdateTime = ref('')
const selectedPeriod = ref('7d')
const customRangeEnabled = ref(false)
const selectedStartDate = ref('')
const selectedEndDate = ref('')
const showMoreMenu = ref(false)
const showSidebarMenu = ref(false)
const currentPath = ref('/pages/mall/analytics/user-analysis')
@@ -297,6 +318,10 @@ onLoad(() => {
})
function calcDateRange() {
if (selectedStartDate.value && selectedEndDate.value) {
return { startDate: new Date(selectedStartDate.value), endDate: new Date(selectedEndDate.value) }
}
const now = new Date()
const endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const days = selectedPeriod.value === '7d' ? 7 : selectedPeriod.value === '30d' ? 30 : selectedPeriod.value === '90d' ? 90 : 365
@@ -440,6 +465,27 @@ async function loadUserData() {
function selectPeriod(p: string) {
selectedPeriod.value = p
customRangeEnabled.value = false
selectedStartDate.value = ''
selectedEndDate.value = ''
loadUserData()
}
function toggleCustomRange() {
customRangeEnabled.value = !customRangeEnabled.value
}
function onDateRangeApply(range: { start: string; end: string }) {
selectedStartDate.value = range.start
selectedEndDate.value = range.end
customRangeEnabled.value = true
loadUserData()
}
function onDateRangeClear() {
selectedStartDate.value = ''
selectedEndDate.value = ''
customRangeEnabled.value = false
loadUserData()
}

View File

@@ -335,7 +335,20 @@ const handleLogin = async () => {
}
const navigateToRegister = () => {
uni.navigateTo({ url: '/pages/user/register' })
const pages = getCurrentPages() as any[]
const currentPage = pages.length > 0 ? pages[pages.length - 1] : null
const opts = currentPage?.options as any
const redirect = opts?.redirect as string | null
if (redirect != null && redirect.length > 0) {
uni.navigateTo({
url: `/pages/user/register?redirect=${redirect}`
})
} else {
uni.navigateTo({
url: '/pages/user/register'
})
}
}
const handleTutorial = () => uni.showToast({ title: '扫码教程开发中', icon: 'none' })
const handleForgotPassword = () => uni.showToast({ title: '忘记密码开发中', icon: 'none' })

View File

@@ -330,9 +330,20 @@
// 跳转到登录页
const navigateToLogin = () => {
uni.navigateTo({
url: '/pages/user/login'
})
const pages = getCurrentPages() as any[]
const currentPage = pages.length > 0 ? pages[pages.length - 1] : null
const opts = currentPage?.options as any
const redirect = opts?.redirect as string | null
if (redirect != null && redirect.length > 0) {
uni.navigateTo({
url: `/pages/user/login?redirect=${redirect}`
})
} else {
uni.navigateTo({
url: '/pages/user/login'
})
}
}
// 跳转到协议页面