feat: implement gas and delivery admin systems

This commit is contained in:
david
2026-07-30 14:16:58 +08:00
parent 1093385f95
commit f5ecc0d973
252 changed files with 49385 additions and 363 deletions

View File

@@ -0,0 +1,12 @@
import { request } from './http';
/** 气站登录和当前账号资料的接口模型。 */
export type LoginData = { username: string; password: string };
export type LoginReply = { access_token: string; token_type: string; identity: string; display_name: string; role_code: string };
export type Profile = { identity: string; username: string; display_name: string; avatar: string; role_code: string; menu_codes: string[] };
export const authApi = {
login: (data: LoginData) => request<LoginReply>('/auth/login', { method: 'POST', body: JSON.stringify(data) }),
profile: () => request<Profile>('/auth/profile'),
changePassword: (currentPassword: string, newPassword: string) => request<{ changed: boolean }>('/auth/password', { method: 'PUT', body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }) }),
};

View File

@@ -0,0 +1,26 @@
/** 气站后台 HTTP 客户端,使用独立 API 前缀和 JWT 存储键。 */
const apiBaseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:12426/heqi/gas/v1';
export const tokenStorageKey = 'gas_admin_token';
export type PageResult<T> = { total: number; list: T[] };
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
const token = localStorage.getItem(tokenStorageKey);
let response: Response;
try {
response = await fetch(`${apiBaseURL}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: token } : {}),
...(init?.headers ?? {}),
},
});
} catch {
throw new Error('无法连接服务器,请确认服务已启动');
}
const payload = (await response.json()) as { code?: number; message?: string; details?: T };
if (!response.ok || payload.code !== 0) throw new Error(payload.message || '请求失败');
return payload.details as T;
}

View File

@@ -0,0 +1,51 @@
import { request } from './http';
import { resourceApi } from './resource';
/** 气站菜单和工作台接口。 */
export interface DashboardMetric {
name: string;
value: number;
}
export interface DashboardDailyMetric {
date: string;
order_count: number;
order_amount: number;
}
export interface DashboardOverview {
gas_basic_count: number;
delivery_basic_count: number;
staff_count: number;
user_count: number;
product_count: number;
active_contract_count: number;
today_order_count: number;
today_order_amount: number;
pending_ticket_count: number;
paid_amount: number;
order_statuses: DashboardMetric[];
product_statuses: DashboardMetric[];
payment_channels: DashboardMetric[];
recent_orders: DashboardDailyMetric[];
}
export type PlatformRole = { identity: string; role_code: string; name: string; location_scope: string; is_system: boolean; status: number };
export type PlatformMenu = { identity: string; parent_identity?: string; group_code: string; name: string; icon: string; path: string; sort_no: number };
export const platformApi = {
overview: () => request<DashboardOverview>('/dashboard/overview'),
listAccount: () => request<{ total: number; list: Record<string, unknown>[] }>('/platform_account'),
listRole: () => resourceApi.list<PlatformRole>('/platform_role'),
createRole: (data: Record<string, unknown>) => resourceApi.create<PlatformRole>('/platform_role', data),
listMenu: () => request<{ total: number; list: PlatformMenu[] }>('/gas_menu'),
listRoleMenuIdentities: (identity: string) =>
request<{ menu_identities: string[] }>(
`/platform_role/${identity}/menu`,
),
replaceRoleMenus: (identity: string, menuIdentities: string[]) =>
request<{ updated: boolean }>(
`/platform_role/${identity}/menu`,
{
method: 'PUT',
body: JSON.stringify({ menu_identities: menuIdentities }),
},
),
};

View File

@@ -0,0 +1,66 @@
import type { ResourceField } from './resources';
export type ResourceFormValue = string | number | boolean | string[] | undefined;
export type ResourceFormMode = 'create' | 'edit';
export function isMissingField(value: ResourceFormValue | null): boolean {
return value === '' || value === null || value === undefined || (Array.isArray(value) && value.length === 0);
}
export function isResourceFieldRequired(
field: ResourceField,
mode: ResourceFormMode,
): boolean {
return Boolean(field.required && !(mode === 'edit' && field.type === 'password'));
}
export function buildResourcePayload(
fields: ResourceField[],
form: Record<string, ResourceFormValue>,
mode: ResourceFormMode = 'create',
): Record<string, unknown> {
const payload: Record<string, unknown> = {};
for (const field of fields) {
if (mode === 'edit' && field.type === 'password') continue;
const value = form[field.key];
if (field.type === 'identity-list' && Array.isArray(value)) {
payload[field.key] = value;
continue;
}
if (isMissingField(value)) {
if (!field.required) continue;
payload[field.key] = value;
continue;
}
switch (field.type) {
case 'number': {
const number = Number(value);
if (!Number.isFinite(number))
throw new Error(`${field.label}必须是数字`);
payload[field.key] = number;
break;
}
case 'money': {
const amount = Number(value);
if (!Number.isFinite(amount))
throw new Error(`${field.label}必须是有效金额`);
payload[field.key] = Math.round(amount * 100);
break;
}
case 'boolean':
payload[field.key] = value === true || value === 'true';
break;
case 'date':
case 'datetime': {
const date = new Date(String(value));
if (Number.isNaN(date.getTime()))
throw new Error(`${field.label}必须是有效日期`);
payload[field.key] = date.toISOString();
break;
}
default:
payload[field.key] = value;
}
}
return payload;
}

View File

@@ -0,0 +1,16 @@
import { request, type PageResult } from './http';
/** 所有标准 CRUD 资源共享的调用方法。 */
export const resourceApi = {
list: <T>(resource: string, page = 1, size = 20, filters: Record<string, string> = {}) => {
const query = new URLSearchParams({ page: String(page), size: String(size), ...filters });
return request<PageResult<T>>(`${resource}?${query.toString()}`);
},
detail: <T>(resource: string, identity: string) => request<T>(`${resource}/${identity}`),
create: <T>(resource: string, data: Record<string, unknown>) => request<T>(resource, { method: 'POST', body: JSON.stringify(data) }),
update: <T>(resource: string, identity: string, data: Record<string, unknown>) => request<T>(`${resource}/${identity}`, { method: 'PUT', body: JSON.stringify(data) }),
updateStatus: (resource: string, identity: string, status: number) => request<{ updated: boolean }>(`${resource}/${identity}/status`, { method: 'PATCH', body: JSON.stringify({ status }) }),
archive: (resource: string, identity: string) => request<{ updated: boolean }>(`${resource}/${identity}`, { method: 'DELETE' }),
action: <T>(resource: string, method: 'POST' | 'PUT' | 'PATCH', data: Record<string, unknown>) =>
request<T>(resource, { method, body: JSON.stringify(data) }),
};

View File

@@ -0,0 +1,442 @@
export type ResourceMode =
| 'writable'
| 'readonly'
| 'append_only'
| 'editable'
| 'managed';
export type ResourcePageKind = 'list' | 'tree';
export type ResourceFieldType =
| 'text'
| 'password'
| 'identity'
| 'identity-list'
| 'number'
| 'money'
| 'boolean'
| 'date'
| 'datetime'
| 'textarea'
| 'select';
export type ResourceField = {
key: string;
label: string;
type?: ResourceFieldType;
required?: boolean;
relation?: string;
options?: Array<{ label: string; value: string | number }>;
};
export type DetailAction = {
name: string;
resource: string;
method?: 'POST' | 'PUT' | 'PATCH';
danger?: boolean;
fields?: ResourceField[];
visibleFor?: { field: string; values: Array<string | number> };
};
export type ResourceUiDefinition = {
key: string;
name: string;
resource: `/${string}`;
title: string;
mode: ResourceMode;
pageKind: ResourcePageKind;
fields: ResourceField[];
detailActions?: DetailAction[];
canCreate: boolean;
canEdit: boolean;
canChangeStatus: boolean;
canArchive: boolean;
accountManagement?: {
resource: `/${string}`;
relationKey: string;
title: string;
};
walletOwnerType?: 'gas' | 'delivery' | 'staff' | 'user';
};
const fieldLabels: Record<string, string> = {
code: '编码',
name: '名称',
username: '用户名',
password: '密码',
display_name: '显示名称',
role_code: '角色编码',
platform_role_code: '平台角色',
credit_code: '统一社会信用代码',
principal: '负责人',
manager: '库房负责人',
phone: '联系电话',
address: '地址',
longitude: '经度',
latitude: '纬度',
delivery_code: '配送站编码',
avatar: '头像',
work_status: '工作状态',
credential_type: '资质类型',
credential_no: '资质编号',
expired_at: '到期时间',
real_name: '实名姓名',
is_default: '默认地址',
params: '智能气阀参数',
produced_at: '生产时间',
enabled_at: '启用时间',
product_status: '智能气阀状态',
contract_status: '合同状态',
order_status: '订单状态',
previous_order_status: '异常前订单状态',
payment_status: '支付状态',
refund_status: '退款状态',
apply_status: '提现状态',
ticket_status: '工单状态',
reconciliation_status: '对账状态',
repair_no: '检修单号',
repair_type: '检修类型',
started_at: '开始时间',
completed_at: '完成时间',
result: '检修结果',
target_product_status: '目标产品状态',
content: '内容',
operator: '操作人员',
remark: '备注',
action: '动作',
occurred_at: '发生时间',
reason: '原因',
operator_identity: '操作人标识',
operator_name: '操作人',
owner_type: '归属类型',
owner_identity: '归属标识',
alipay_id: '支付宝账号',
alipay_name: '支付宝账户名',
wxpay_id: '微信账号',
wxpay_name: '微信账户名',
balance: '余额(元)',
withdrawal_balance: '可提现余额(元)',
card_no_last4: '银行卡末四位',
bank_name: '银行名称',
card_owner: '持卡人',
payment_no: '支付单号',
order_no: '订单号',
trade_no: '第三方流水号',
payment_type: '支付业务类型',
pay_channel: '支付渠道',
pay_type: '支付类型',
amount: '金额(元)',
fee: '手续费(元)',
args: '支付参数',
callback_msg: '回调信息',
record_no: '流水号',
request_no: '请求流水号',
direction: '收支方向',
trade_type: '交易类型',
balance_after: '变动后余额(元)',
withdrawal_balance_after: '变动后可提现余额(元)',
refund_no: '退款单号',
cash_no: '提现单号',
channel: '渠道',
review_reason: '审核原因',
reviewed_at: '审核时间',
reviewer_name: '审核人',
reviewer_identity: '审核人标识',
ymd: '日期',
ym: '月份',
contract_no: '合同编号',
title: '标题',
terms: '合同条款',
file_uri: '附件地址',
default_delivery_fee: '默认配送费(元)',
signed_at: '签署时间',
effective_at: '生效时间',
unit_price: '单价(元)',
unbound_at: '解绑时间',
revision_no: '修订号',
creator_type: '创建方类型',
creator_identity: '创建方标识',
contact_name: '联系人',
contact_phone: '联系电话',
discount_amount: '优惠金额(元)',
total_amount: '总金额(元)',
delivery_fee: '配送费(元)',
assigned_at: '分配时间',
from_status: '原状态',
to_status: '新状态',
track_no: '轨迹编号',
attempt_no: '尝试次数',
point_type: '轨迹点类型',
confirmed_at: '确认时间',
sort_no: '排序',
product_code: '商品编码',
price_amount: '售价(元)',
stock_quantity: '库存',
value: '属性值',
image_uri: '图片地址',
is_cover: '封面图',
quantity: '数量',
selected: '是否选中',
product_snapshot: '商品快照',
sale_amount: '成交金额(元)',
score: '评分',
paid_at: '支付时间',
settlement_no: '结算单号',
subject_type: '结算主体类型',
period_start: '结算开始时间',
period_end: '结算结束时间',
bill_date: '账单日期',
difference_amount: '差异金额(元)',
content_type: '内容类型',
body: '正文',
version_no: '版本号',
publish_status: '发布状态',
ticket_no: '工单号',
category: '分类',
priority: '优先级',
location_scope: '坐标权限',
parent_identity: '父级',
group_code: '菜单分组编码',
icon: '图标',
path: '路由',
menu_identities: '菜单权限',
status: '状态',
};
const numbers = new Set([
'sort_no', 'stock_quantity', 'quantity', 'score', 'version_no',
'attempt_no', 'target_product_status',
]);
const money = new Set([
'unit_price', 'amount', 'fee', 'balance', 'withdrawal_balance',
'balance_after', 'withdrawal_balance_after', 'default_delivery_fee',
'discount_amount', 'total_amount', 'delivery_fee', 'price_amount',
'sale_amount', 'difference_amount',
]);
const booleans = new Set(['is_default', 'is_cover', 'selected', 'withdrawable']);
const dates = new Set(['bill_date']);
const datetimes = new Set([
'expired_at', 'produced_at', 'enabled_at', 'started_at', 'completed_at',
'occurred_at', 'reviewed_at', 'signed_at', 'effective_at', 'unbound_at',
'assigned_at', 'confirmed_at', 'paid_at', 'period_start', 'period_end',
]);
const textareas = new Set([
'params', 'content', 'remark', 'reason', 'terms', 'args', 'callback_msg',
'review_reason', 'product_snapshot', 'body',
]);
function f(key: string, options: Partial<ResourceField> = {}): ResourceField {
const type: ResourceFieldType = key.endsWith('_identity')
? 'identity'
: money.has(key)
? 'money'
: numbers.has(key)
? 'number'
: booleans.has(key)
? 'boolean'
: dates.has(key)
? 'date'
: datetimes.has(key)
? 'datetime'
: textareas.has(key)
? 'textarea'
: key === 'password'
? 'password'
: 'text';
return { key, label: fieldLabels[key] ?? key, type, ...options };
}
function relation(key: string, resource: string, required = false): ResourceField {
return f(key, { type: 'identity', relation: resource, required });
}
function define(
name: string,
title: string,
mode: ResourceMode,
fields: ResourceField[],
pageKind: ResourcePageKind = 'list',
detailActions?: DetailAction[],
capabilities: Partial<
Pick<
ResourceUiDefinition,
'canCreate' | 'canEdit' | 'canChangeStatus' | 'canArchive'
>
> = {},
): ResourceUiDefinition {
const defaults = {
canCreate: mode === 'writable' || mode === 'editable' || mode === 'append_only',
canEdit: mode === 'writable' || mode === 'editable',
canChangeStatus: mode === 'writable' || mode === 'editable',
canArchive: mode === 'writable',
};
return {
key: name.replace(/_/g, '-'),
name,
resource: `/${name}`,
title,
mode,
pageKind,
fields,
...defaults,
...capabilities,
...(detailActions ? { detailActions } : {}),
};
}
const reason = [f('reason', { required: true })];
const platformResources: ResourceUiDefinition[] = [
{ ...define('gas_basic', '气站管理', 'writable', [f('code', { required: true }), f('name', { required: true }), f('credit_code'), f('principal'), f('address'), f('longitude'), f('latitude')]), accountManagement: { resource: '/gas_account', relationKey: 'gas_basic_identity', title: '气站账户' }, walletOwnerType: 'gas' },
define('gas_account', '气站账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('display_name'), f('role_code'), relation('gas_basic_identity', '/gas_basic', true)]),
{ ...define('delivery_basic', '配送点管理', 'writable', [f('delivery_code', { required: true }), f('name', { required: true }), relation('gas_basic_identity', '/gas_basic'), f('principal'), f('address')]), accountManagement: { resource: '/delivery_account', relationKey: 'delivery_basic_identity', title: '配送点账户' }, walletOwnerType: 'delivery' },
define('delivery_account', '配送站账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('display_name'), f('role_code'), relation('delivery_basic_identity', '/delivery_basic', true)]),
{ ...define('staff_account', '工作人员', 'writable', [f('username', { required: true }), f('password', { required: true }), f('name', { required: true }), f('phone'), f('avatar'), f('role_code', { required: true, type: 'select', options: [{ label: '安装人员', value: 'installer' }, { label: '配送人员', value: 'delivery' }, { label: '运维人员', value: 'operations' }] }), relation('gas_basic_identity', '/gas_basic'), relation('delivery_basic_identity', '/delivery_basic'), f('work_status', { type: 'select', options: [{ label: '在岗', value: 'on_duty' }, { label: '离岗', value: 'off_duty' }] })]), walletOwnerType: 'staff' },
define('staff_credential', '工作人员资质', 'writable', [relation('staff_account_identity', '/staff_account', true), f('credential_type', { required: true }), f('credential_no'), f('expired_at')]),
{ ...define('user_account', '用户账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('name', { required: true }), f('phone'), f('avatar'), f('real_name')]), walletOwnerType: 'user' },
define('user_address', '用户地址', 'writable', [relation('user_account_identity', '/user_account', true), f('address', { required: true }), f('longitude'), f('latitude'), f('is_default')]),
define('user_service_relation', '用户服务关系', 'writable', [relation('user_account_identity', '/user_account', true), relation('gas_basic_identity', '/gas_basic'), relation('delivery_basic_identity', '/delivery_basic'), relation('staff_account_identity', '/staff_account')]),
define('product_type', '智能气阀类型', 'editable', [f('code', { required: true }), f('name', { required: true })]),
define('product_warehouse', '智能气阀库房', 'editable', [f('code', { required: true }), f('name', { required: true }), f('address'), f('manager'), f('phone')]),
define('product_info', '智能气阀', 'editable', [f('code', { required: true }), f('name', { required: true }), relation('product_type_identity', '/product_type', true), f('params', { required: true }), relation('warehouse_identity', '/product_warehouse'), relation('gas_basic_identity', '/gas_basic'), relation('delivery_basic_identity', '/delivery_basic'), relation('user_account_identity', '/user_account'), f('produced_at', { required: true })], 'list', [
{ name: '修改智能气阀状态', resource: '/product_info/:identity/lifecycle', method: 'PATCH', fields: [f('product_status', { required: true, type: 'select', options: [{ label: '待处理', value: 10 }, { label: '在库', value: 28 }, { label: '运输中', value: 29 }, { label: '使用中', value: 30 }, { label: '维修中', value: 31 }, { label: '已报废', value: 27 }] })] },
]),
define('product_repair', '智能气阀检修记录', 'editable', [relation('product_info_identity', '/product_info', true), f('repair_no', { required: true }), f('repair_type', { required: true }), f('started_at', { required: true }), f('completed_at'), f('result', { type: 'select', options: [{ label: '待处理', value: 'pending' }, { label: '通过', value: 'passed' }, { label: '未通过', value: 'failed' }] }), f('target_product_status', { type: 'select', options: [{ label: '在库', value: 28 }, { label: '运输中', value: 29 }, { label: '使用中', value: 30 }, { label: '报废', value: 27 }] }), f('content'), f('operator'), f('remark')]),
define('product_owner', '智能气阀归属记录', 'readonly', []),
define('gasorder_contract', '配送合同', 'managed', [f('contract_no', { required: true }), relation('user_account_identity', '/user_account', true), relation('gas_basic_identity', '/gas_basic', true), relation('delivery_basic_identity', '/delivery_basic'), f('title', { required: true }), f('terms'), f('file_uri'), f('default_delivery_fee'), f('signed_at', { required: true }), f('effective_at', { required: true }), f('expired_at')], 'list', [
{ name: '启用合同', resource: '/gasorder_contract/:identity/activate', fields: reason, visibleFor: { field: 'contract_status', values: [10] } },
{ name: '续签合同', resource: '/gasorder_contract/:identity/renew', fields: [f('effective_at', { required: true }), f('expired_at'), ...reason], visibleFor: { field: 'contract_status', values: [11, 12] } },
{ name: '终止合同', resource: '/gasorder_contract/:identity/terminate', danger: true, fields: reason, visibleFor: { field: 'contract_status', values: [11] } },
], { canCreate: true, canEdit: true }),
define('gasorder_contract_product', '合同气瓶', 'append_only', [relation('gasorder_contract_identity', '/gasorder_contract', true), relation('product_info_identity', '/product_info', true), f('unit_price')], 'list', [
{ name: '解绑气瓶', resource: '/gasorder_contract_product/:identity/unbind', danger: true, fields: reason },
]),
define('gasorder_contract_revision', '合同修订记录', 'readonly', []),
define('gasorder_basic', '气体配送订单', 'append_only', [f('request_no', { required: true }), relation('gasorder_contract_identity', '/gasorder_contract', true), f('creator_type', { required: true, type: 'select', options: [{ label: '用户', value: 'user' }, { label: '工作人员', value: 'staff' }, { label: '配送站', value: 'delivery' }, { label: '气站', value: 'gas' }] }), f('creator_identity', { required: true }), relation('user_address_identity', '/user_address', true), f('gasorder_contract_product_identities', { required: true, type: 'identity-list', relation: '/gasorder_contract_product' }), f('contact_name', { required: true }), f('contact_phone', { required: true }), f('discount_amount'), f('remark')], 'list', [
{ name: '分配订单', resource: '/gasorder_basic/:identity/assign', fields: [relation('delivery_basic_identity', '/delivery_basic', true), relation('staff_account_identity', '/staff_account', true), ...reason], visibleFor: { field: 'order_status', values: [16, 18] } },
{ name: '开始罐装', resource: '/gasorder_basic/:identity/filling', fields: reason, visibleFor: { field: 'order_status', values: [18] } },
{ name: '待配送', resource: '/gasorder_basic/:identity/ready', fields: reason, visibleFor: { field: 'order_status', values: [19] } },
{ name: '开始配送', resource: '/gasorder_basic/:identity/delivering', fields: reason, visibleFor: { field: 'order_status', values: [20] } },
{ name: '等待签收', resource: '/gasorder_basic/:identity/awaiting-confirmation', fields: reason, visibleFor: { field: 'order_status', values: [33] } },
{ name: '完成订单', resource: '/gasorder_basic/:identity/complete', fields: [...reason, f('confirm_type', { required: true }), f('recipient_name', { required: true }), f('recipient_phone'), f('proof_uri'), f('remark')], visibleFor: { field: 'order_status', values: [34] } },
{ name: '标记异常', resource: '/gasorder_basic/:identity/exception', fields: reason, visibleFor: { field: 'order_status', values: [19, 20, 33, 34] } },
{ name: '恢复订单', resource: '/gasorder_basic/:identity/recover', fields: reason, visibleFor: { field: 'order_status', values: [21] } },
{ name: '取消订单', resource: '/gasorder_basic/:identity/cancel', danger: true, fields: reason, visibleFor: { field: 'order_status', values: [16, 18] } },
]),
define('gasorder_item', '订单明细', 'readonly', []),
define('gasorder_assign', '分配记录', 'readonly', []),
define('gasorder_status', '状态记录', 'readonly', []),
define('gasorder_track', '运行轨迹', 'readonly', []),
define('gasorder_track_point', '轨迹点', 'readonly', []),
define('gasorder_confirm', '确认记录', 'readonly', []),
define('gasorder_payment', '支付记录', 'readonly', []),
define('ec_category', '商品分类', 'writable', [relation('parent_identity', '/ec_category'), f('name', { required: true }), f('sort_no')], 'tree'),
define('ec_product', '商品', 'writable', [relation('ec_category_identity', '/ec_category', true), f('product_code', { required: true }), f('name', { required: true }), f('price_amount', { required: true }), f('stock_quantity')]),
define('ec_product_attribute', '商品属性', 'writable', [relation('ec_product_identity', '/ec_product', true), f('name', { required: true }), f('value', { required: true }), f('sort_no')]),
define('ec_product_image', '商品图片', 'writable', [relation('ec_product_identity', '/ec_product', true), f('image_uri', { required: true }), f('sort_no'), f('is_cover')]),
define('ec_cart', '购物车', 'readonly', []),
define('ec_order', '商城订单', 'readonly', []),
define('ec_order_item', '商城订单明细', 'readonly', []),
define('ec_review', '商品评价', 'readonly', []),
define('wallet_basic', '钱包', 'readonly', [f('owner_type'), f('owner_identity'), f('alipay_id'), f('alipay_name'), f('wxpay_id'), f('wxpay_name'), f('balance'), f('withdrawal_balance')], 'list', [
{ name: '后台充值', resource: '/wallet_basic/:identity/recharge', fields: [f('request_no', { required: true }), f('amount', { required: true }), f('withdrawable'), ...reason, f('remark')] },
{ name: '修改钱包状态', resource: '/wallet_basic/:identity/status', method: 'PATCH', fields: [f('status', { required: true, type: 'select', options: [{ label: '启用', value: 1 }, { label: '停用', value: 2 }, { label: '冻结', value: 4 }] })] },
]),
define('wallet_bank', '银行卡', 'readonly', []),
define('wallet_payment', '钱包支付记录', 'readonly', []),
define('wallet_record', '钱包流水', 'readonly', []),
define('wallet_refund', '退款记录', 'readonly', []),
define('wallet_apply_cash', '提现记录', 'readonly', [
f('cash_no'),
f('wallet_basic_identity', { type: 'identity' }),
f('amount'),
f('apply_status', { type: 'select', options: [
{ label: '待处理', value: 10 },
{ label: '已通过', value: 25 },
{ label: '已驳回', value: 26 },
{ label: '已完成', value: 23 },
] }),
f('reviewer_name'),
f('review_reason'),
f('reviewed_at'),
f('completed_at'),
f('channel'),
f('trade_no'),
f('remark'),
], 'list', [
{ name: '审核通过', resource: '/wallet_apply_cash/:identity/approve', fields: reason, visibleFor: { field: 'apply_status', values: [10] } },
{ name: '审核驳回', resource: '/wallet_apply_cash/:identity/reject', danger: true, fields: reason, visibleFor: { field: 'apply_status', values: [10] } },
{ name: '标记处理完成', resource: '/wallet_apply_cash/:identity/complete', fields: [f('trade_no', { required: true }), f('callback_msg')], visibleFor: { field: 'apply_status', values: [25] } },
]),
define('fin_payment', '财务支付记录', 'readonly', []),
define('fin_settlement', '财务结算', 'writable', [f('settlement_no', { required: true }), f('subject_type', { required: true }), f('subject_identity', { required: true }), f('period_start', { required: true }), f('period_end', { required: true })]),
define('fin_reconciliation', '财务对账', 'readonly', []),
define('cms_content', '内容', 'writable', [f('content_type', { required: true }), f('title', { required: true }), f('body', { required: true }), f('version_no'), f('publish_status')]),
define('cs_ticket', '客服工单', 'writable', [relation('user_account_identity', '/user_account', true), f('ticket_no', { required: true }), f('category', { required: true }), f('priority', { required: true })]),
define('platform_account', '平台账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('display_name'), f('avatar'), f('platform_role_code', { required: true }), f('phone')]),
define('platform_role', '平台角色', 'writable', [f('role_code', { required: true }), f('name', { required: true }), f('location_scope', { required: true, type: 'select', options: [{ label: '脱敏坐标', value: 'standard' }, { label: '精确坐标', value: 'precise' }] })], 'list', [
{ name: '分配菜单', resource: '/platform_role/:identity/menu', method: 'PUT', fields: [f('menu_identities', { type: 'identity-list', relation: '/platform_menu' })] },
]),
define('platform_menu', '平台菜单', 'readonly', [], 'tree'),
];
const gasOverrides: ResourceUiDefinition[] = [
{ ...define('delivery_basic', '配送点管理', 'writable', [f('delivery_code', { required: true }), f('name', { required: true }), f('principal'), f('address')]), accountManagement: { resource: '/delivery_account', relationKey: 'delivery_basic_identity', title: '配送点账户' } },
define('delivery_account', '配送点账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('display_name')], 'list', [
{ name: '重置密码', resource: '/delivery_account/:identity/password', method: 'PUT', fields: [f('password', { required: true })] },
]),
define('staff_account', '工作人员', 'writable', [f('username', { required: true }), f('password', { required: true }), f('name', { required: true }), f('phone'), f('avatar'), f('role_code', { required: true, type: 'select', options: [{ label: '安装人员', value: 'installer' }, { label: '配送人员', value: 'delivery' }, { label: '运维人员', value: 'operations' }] }), relation('delivery_basic_identity', '/delivery_basic'), f('work_status', { type: 'select', options: [{ label: '在岗', value: 'on_duty' }, { label: '离岗', value: 'off_duty' }] })], 'list', [
{ name: '重置密码', resource: '/staff_account/:identity/password', method: 'PUT', fields: [f('password', { required: true })] },
]),
define('staff_credential', '工作人员资质', 'writable', [relation('staff_account_identity', '/staff_account', true), f('credential_type', { required: true }), f('credential_no'), f('expired_at')]),
define('user_account', '用户账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('name', { required: true }), f('phone'), f('avatar'), f('real_name'), relation('delivery_basic_identity', '/delivery_basic', true)], 'list', [
{ name: '重置密码', resource: '/user_account/:identity/password', method: 'PUT', fields: [f('password', { required: true })] },
]),
define('user_address', '用户地址', 'writable', [relation('user_account_identity', '/user_account', true), f('address', { required: true }), f('longitude'), f('latitude'), f('is_default')]),
define('gasorder_contract', '配送合同', 'managed', [f('contract_no', { required: true }), relation('user_account_identity', '/user_account', true), relation('delivery_basic_identity', '/delivery_basic'), f('title', { required: true }), f('terms'), f('file_uri'), f('default_delivery_fee'), f('signed_at', { required: true }), f('effective_at', { required: true }), f('expired_at')], 'list', [
{ name: '启用合同', resource: '/gasorder_contract/:identity/activate', fields: reason, visibleFor: { field: 'contract_status', values: [10] } },
{ name: '续签合同', resource: '/gasorder_contract/:identity/renew', fields: [f('effective_at', { required: true }), f('expired_at'), ...reason], visibleFor: { field: 'contract_status', values: [11, 12, 13] } },
{ name: '终止合同', resource: '/gasorder_contract/:identity/terminate', danger: true, fields: reason, visibleFor: { field: 'contract_status', values: [11] } },
], { canCreate: true, canEdit: true }),
define('gasorder_contract_product', '合同气瓶', 'append_only', [relation('gasorder_contract_identity', '/gasorder_contract', true), relation('product_info_identity', '/product_info', true), f('unit_price')], 'list', [
{ name: '解绑气瓶', resource: '/gasorder_contract_product/:identity/unbind', danger: true, fields: reason },
]),
define('gasorder_contract_revision', '合同修订记录', 'readonly', []),
define('product_info', '合同可选气瓶', 'readonly', []),
define('gasorder_basic', '燃气配送订单', 'append_only', [f('request_no', { required: true }), relation('gasorder_contract_identity', '/gasorder_contract', true), relation('user_address_identity', '/user_address', true), f('gasorder_contract_product_identities', { required: true, type: 'identity-list', relation: '/gasorder_contract_product' }), f('contact_name', { required: true }), f('contact_phone', { required: true }), f('discount_amount'), f('remark')], 'list', [
{ name: '分配订单', resource: '/gasorder_basic/:identity/assign', fields: [relation('delivery_basic_identity', '/delivery_basic', true), relation('staff_account_identity', '/staff_account', true), ...reason], visibleFor: { field: 'order_status', values: [16, 18] } },
{ name: '开始罐装', resource: '/gasorder_basic/:identity/filling', fields: reason, visibleFor: { field: 'order_status', values: [18] } },
{ name: '待配送', resource: '/gasorder_basic/:identity/ready', fields: reason, visibleFor: { field: 'order_status', values: [19] } },
{ name: '标记异常', resource: '/gasorder_basic/:identity/exception', fields: reason, visibleFor: { field: 'order_status', values: [19, 20, 33, 34] } },
{ name: '恢复订单', resource: '/gasorder_basic/:identity/recover', fields: reason, visibleFor: { field: 'order_status', values: [21] } },
{ name: '取消订单', resource: '/gasorder_basic/:identity/cancel', danger: true, fields: reason, visibleFor: { field: 'order_status', values: [16, 18] } },
]),
define('wallet_basic', '钱包', 'readonly', []),
define('wallet_bank', '银行卡', 'readonly', []),
define('wallet_payment', '支付记录', 'readonly', []),
define('wallet_record', '钱包流水', 'readonly', []),
define('wallet_refund', '退款记录', 'readonly', []),
define('wallet_apply_cash', '提现申请', 'append_only', [relation('wallet_bank_identity', '/wallet_bank'), f('request_no', { required: true }), f('amount', { required: true }), f('channel', { required: true }), f('remark')]),
define('fin_settlement', '财务结算', 'readonly', []),
define('fin_reconciliation', '财务对账', 'readonly', []),
define('cs_ticket', '客服工单', 'writable', [relation('user_account_identity', '/user_account', true), f('ticket_no', { required: true }), f('category', { required: true }), f('priority', { required: true })]),
];
const gasResourceNames = new Set(gasOverrides.map((item) => item.name));
export const resources = gasOverrides.map((override) => {
const source = platformResources.find((item) => item.name === override.name);
return source ? { ...source, ...override } : override;
}).filter((item) => gasResourceNames.has(item.name));
export const resourceByPath = Object.fromEntries(
resources.map((definition) => [definition.resource, definition]),
) as Record<string, ResourceUiDefinition>;
export function getResource(resourcePath: string): ResourceUiDefinition {
const definition = resourceByPath[resourcePath];
if (!definition) throw new Error(`未知资源:${resourcePath}`);
return definition;
}

View File

@@ -0,0 +1,9 @@
<template>
<a-config-provider :locale="zhCN">
<router-view />
</a-config-provider>
</template>
<script lang="ts" setup>
import zhCN from '@arco-design/web-vue/es/locale/lang/zh-cn';
</script>

22
frontend/gas_admin/src/app/env.d.ts vendored Normal file
View File

@@ -0,0 +1,22 @@
/// <reference types="vite/client" />
/// <reference types="vue/jsx" />
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<
Record<string, unknown>,
Record<string, unknown>,
unknown
>;
export default component;
}
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string;
readonly VITE_ERROR_REPORT_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -0,0 +1,36 @@
import ArcoVue from '@arco-design/web-vue';
import ArcoVueIcon from '@arco-design/web-vue/es/icon';
import { createApp } from 'vue';
import globalComponents from '@/components';
import '@/assets/style/global.less';
import { setupHttp } from '@/plugins/http';
import directive from '@/directive';
import router from '@/router';
import store from '@/store';
import setupErrorReport from '@/utils/error-report';
import App from './App.vue';
async function bootstrap() {
if (import.meta.env.DEV) {
await import('@/mocks');
}
const app = createApp(App);
app.use(ArcoVue, {});
app.use(ArcoVueIcon);
app.use(router);
app.use(store);
app.use(globalComponents);
app.use(directive);
setupHttp();
setupErrorReport(
app,
import.meta.env.VITE_ERROR_REPORT_URL?.trim() ?? '',
);
app.mount('#app');
}
bootstrap();

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View File

@@ -0,0 +1,12 @@
<svg width="33" height="33" viewBox="0 0 33 33" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.37754 16.9795L12.7498 9.43027C14.7163 7.41663 17.9428 7.37837 19.9564 9.34482C19.9852 9.37297 20.0137 9.40145 20.0418 9.43027L20.1221 9.51243C22.1049 11.5429 22.1049 14.7847 20.1221 16.8152L12.7498 24.3644C10.7834 26.378 7.55686 26.4163 5.54322 24.4498C5.5144 24.4217 5.48592 24.3932 5.45777 24.3644L5.37754 24.2822C3.39468 22.2518 3.39468 19.0099 5.37754 16.9795Z" fill="#12D2AC"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.0479 9.43034L27.3399 16.8974C29.3674 18.9735 29.3674 22.2883 27.3399 24.3644C25.3735 26.3781 22.147 26.4163 20.1333 24.4499C20.1045 24.4217 20.076 24.3933 20.0479 24.3644L12.7558 16.8974C10.7284 14.8213 10.7284 11.5065 12.7558 9.43034C14.7223 7.4167 17.9488 7.37844 19.9624 9.34489C19.9912 9.37304 20.0197 9.40152 20.0479 9.43034Z" fill="#307AF2"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.1321 9.52163L23.6851 13.1599L16.3931 20.627L9.10103 13.1599L12.6541 9.52163C14.6707 7.45664 17.9794 7.4174 20.0444 9.434C20.074 9.46286 20.1032 9.49207 20.1321 9.52163Z" fill="#0057FE"/>
</g>
<defs>
<clipPath id="clip0">
<rect width="26" height="19" fill="white" transform="translate(3.5 7)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,19 @@
// ==============breakpoint============
// Extra small screen / phone
@screen-xs: 480px;
// Small screen / tablet
@screen-sm: 576px;
// Medium screen / desktop
@screen-md: 768px;
// Large screen / wide desktop
@screen-lg: 992px;
// Extra large screen / full hd
@screen-xl: 1200px;
// Extra extra large screen / large desktop
@screen-xxl: 1600px;

View File

@@ -0,0 +1,94 @@
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
font-size: 14px;
background-color: var(--color-bg-1);
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
}
.echarts-tooltip-diy {
background: linear-gradient(
304.17deg,
rgba(253, 254, 255, 0.6) -6.04%,
rgba(244, 247, 252, 0.6) 85.2%
) !important;
border: none !important;
backdrop-filter: blur(10px) !important;
/* Note: backdrop-filter has minimal browser support */
border-radius: 6px !important;
.content-panel {
display: flex;
justify-content: space-between;
padding: 0 9px;
background: rgba(255, 255, 255, 0.8);
width: 164px;
height: 32px;
line-height: 32px;
box-shadow: 6px 0px 20px rgba(34, 87, 188, 0.1);
border-radius: 4px;
margin-bottom: 4px;
}
.tooltip-title {
margin: 0 0 10px 0;
}
p {
margin: 0;
}
.tooltip-title,
.tooltip-value {
font-size: 13px;
line-height: 15px;
display: flex;
align-items: center;
text-align: right;
color: #1d2129;
font-weight: bold;
}
.tooltip-item-icon {
display: inline-block;
margin-right: 8px;
width: 10px;
height: 10px;
border-radius: 50%;
}
}
.general-card {
border-radius: 4px;
border: none;
& > .arco-card-header {
height: auto;
padding: 20px;
border: none;
}
& > .arco-card-body {
padding: 0 20px 20px 20px;
}
}
.split-line {
border-color: rgb(var(--gray-2));
}
.arco-table-cell {
.circle {
display: inline-block;
margin-right: 4px;
width: 6px;
height: 6px;
border-radius: 50%;
background-color: rgb(var(--blue-6));
&.pass {
background-color: rgb(var(--green-6));
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
<template>
<a-breadcrumb class="container-breadcrumb">
<a-breadcrumb-item>
<icon-apps />
</a-breadcrumb-item>
<a-breadcrumb-item v-for="item in items" :key="item">
{{ item }}
</a-breadcrumb-item>
</a-breadcrumb>
</template>
<script lang="ts" setup>
import type { PropType } from 'vue';
defineProps({
items: {
type: Array as PropType<string[]>,
default() {
return [];
},
},
});
</script>
<style scoped lang="less">
.container-breadcrumb {
margin: 16px 0;
:deep(.arco-breadcrumb-item) {
color: rgb(var(--gray-6));
&:last-child {
color: rgb(var(--gray-8));
}
}
}
</style>

View File

@@ -0,0 +1,36 @@
<template>
<VChart
v-if="renderChart"
:option="option"
autoresize
:style="{ width, height }"
/>
</template>
<script lang="ts" setup>
import type { EChartsOption } from 'echarts';
import { nextTick, ref } from 'vue';
import VChart from 'vue-echarts';
defineProps({
option: {
type: Object as () => EChartsOption,
default: () => ({}),
},
width: {
type: String,
default: '100%',
},
height: {
type: String,
default: '100%',
},
});
const renderChart = ref(false);
nextTick(() => {
renderChart.value = true;
});
</script>
<style scoped lang="less"></style>

View File

@@ -0,0 +1,16 @@
<template>
<a-layout-footer class="footer">禾气气站管理系统</a-layout-footer>
</template>
<script lang="ts" setup></script>
<style lang="less" scoped>
.footer {
display: flex;
align-items: center;
justify-content: center;
height: 40px;
color: var(--color-text-2);
text-align: center;
}
</style>

View File

@@ -0,0 +1,35 @@
import { BarChart, LineChart, PieChart, RadarChart } from 'echarts/charts';
import {
DataZoomComponent,
GraphicComponent,
GridComponent,
LegendComponent,
TooltipComponent,
} from 'echarts/components';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import type { App } from 'vue';
import Breadcrumb from './breadcrumb/index.vue';
import Chart from './chart/index.vue';
// Manually introduce ECharts modules to reduce packing size
use([
CanvasRenderer,
BarChart,
LineChart,
PieChart,
RadarChart,
GridComponent,
TooltipComponent,
LegendComponent,
DataZoomComponent,
GraphicComponent,
]);
export default {
install(Vue: App) {
Vue.component('Chart', Chart);
Vue.component('Breadcrumb', Breadcrumb);
},
};

View File

@@ -0,0 +1,158 @@
<script lang="tsx">
import { compile, computed, defineComponent, h, ref } from 'vue';
import type { RouteMeta } from 'vue-router';
import { type RouteRecordRaw, useRoute, useRouter } from 'vue-router';
import { useAppStore } from '@/store';
import { openWindow, regexUrl } from '@/utils';
import { listenerRouteChange } from '@/utils/route-listener';
import useMenuTree from './use-menu-tree';
export default defineComponent({
emit: ['collapse'],
setup() {
const appStore = useAppStore();
const router = useRouter();
const route = useRoute();
const { menuTree } = useMenuTree();
const collapsed = computed({
get() {
if (appStore.device === 'desktop') return appStore.menuCollapse;
return false;
},
set(value: boolean) {
appStore.updateSettings({ menuCollapse: value });
},
});
const topMenu = computed(() => appStore.topMenu);
const openKeys = ref<string[]>([]);
const selectedKey = ref<string[]>([]);
const goto = (item: RouteRecordRaw) => {
// Open external link
if (regexUrl.test(item.path)) {
openWindow(item.path);
selectedKey.value = [item.name as string];
return;
}
// Eliminate external link side effects
const { hideInMenu, activeMenu } = item.meta as RouteMeta;
if (route.name === item.name && !hideInMenu && !activeMenu) {
selectedKey.value = [item.name as string];
return;
}
// Trigger router change
router.push({
name: item.name,
});
};
const findMenuOpenKeys = (target: string) => {
const result: string[] = [];
let isFind = false;
const backtrack = (item: RouteRecordRaw, keys: string[]) => {
if (item.name === target) {
isFind = true;
result.push(...keys);
return;
}
if (item.children?.length) {
item.children.forEach((el) => {
backtrack(el, [...keys, el.name as string]);
});
}
};
menuTree.value.forEach((el: RouteRecordRaw) => {
if (isFind) return; // Performance optimization
backtrack(el, [el.name as string]);
});
return result;
};
listenerRouteChange((newRoute) => {
const { requiresAuth, activeMenu, hideInMenu } = newRoute.meta;
if (requiresAuth && (!hideInMenu || activeMenu)) {
const menuOpenKeys = findMenuOpenKeys(
(activeMenu || newRoute.name) as string,
);
const keySet = new Set([...menuOpenKeys, ...openKeys.value]);
openKeys.value = [...keySet];
selectedKey.value = [
activeMenu || menuOpenKeys[menuOpenKeys.length - 1],
];
}
}, true);
const setCollapse = (val: boolean) => {
if (appStore.device === 'desktop')
appStore.updateSettings({ menuCollapse: val });
};
const renderSubMenu = () => {
function travel(_route: RouteRecordRaw[], nodes = []) {
if (_route) {
_route.forEach((element) => {
// This is demo, modify nodes as needed
const icon = element?.meta?.icon
? () => h(compile(`<${element?.meta?.icon}/>`))
: null;
const node =
element?.children && element?.children.length !== 0 ? (
<a-sub-menu
key={element?.name}
v-slots={{
icon,
title: () => String(element?.meta?.title ?? ''),
}}
>
{travel(element?.children)}
</a-sub-menu>
) : (
<a-menu-item
key={element?.name}
v-slots={{ icon }}
onClick={() => goto(element)}
>
{String(element?.meta?.title ?? '')}
</a-menu-item>
);
nodes.push(node as never);
});
}
return nodes;
}
return travel(menuTree.value);
};
return () => (
<a-menu
mode={topMenu.value ? 'horizontal' : 'vertical'}
v-model:collapsed={collapsed.value}
v-model:open-keys={openKeys.value}
show-collapse-button={appStore.device !== 'mobile'}
auto-open={false}
selected-keys={selectedKey.value}
auto-open-selected={true}
level-indent={34}
style="height: 100%;width:100%;"
onCollapse={setCollapse}
>
{renderSubMenu()}
</a-menu>
);
},
});
</script>
<style lang="less" scoped>
:deep(.arco-menu-inner) {
.arco-menu-inline-header {
display: flex;
align-items: center;
}
.arco-icon {
&:not(.arco-icon-down) {
font-size: 18px;
}
}
}
</style>

View File

@@ -0,0 +1,69 @@
import { cloneDeep } from 'lodash-es';
import { computed } from 'vue';
import type { RouteRecordNormalized, RouteRecordRaw } from 'vue-router';
import usePermission from '@/hooks/permission';
import appClientMenus from '@/router/app-menus';
import { useAppStore } from '@/store';
export default function useMenuTree() {
const permission = usePermission();
const appStore = useAppStore();
const appRoute = computed(() => {
if (appStore.menuFromServer) {
return appStore.appAsyncMenus;
}
return appClientMenus;
});
const menuTree = computed(() => {
const copyRouter = cloneDeep(appRoute.value) as RouteRecordNormalized[];
copyRouter.sort((a: RouteRecordNormalized, b: RouteRecordNormalized) => {
return (a.meta.order || 0) - (b.meta.order || 0);
});
function travel(_routes: RouteRecordRaw[], layer: number) {
if (!_routes) return null;
const collector: any = _routes.map((element) => {
// no access
if (!permission.accessRouter(element)) {
return null;
}
// leaf node
if (element.meta?.hideChildrenInMenu || !element.children) {
element.children = [];
return element;
}
// route filter hideInMenu true
element.children = element.children.filter(
(x) => x.meta?.hideInMenu !== true,
);
// Associated child node
const subItem = travel(element.children, layer + 1);
if (subItem.length) {
element.children = subItem;
return element;
}
// the else logic
if (layer > 1) {
element.children = subItem;
return element;
}
if (element.meta?.hideInMenu === false) {
return element;
}
return null;
});
return collector.filter(Boolean);
}
return travel(copyRouter, 0);
});
return {
menuTree,
};
}

View File

@@ -0,0 +1,120 @@
<template>
<a-spin style="display: block" :loading="loading">
<a-tabs v-model:activeKey="messageType" type="rounded" destroy-on-hide>
<a-tab-pane v-for="item in tabList" :key="item.key">
<template #title>
<span> {{ item.title }}{{ formatUnreadLength(item.key) }} </span>
</template>
<a-result v-if="!renderList.length" status="404">
<template #subtitle> 暂无消息 </template>
</a-result>
<List
:render-list="renderList"
:unread-count="unreadCount"
@item-click="handleItemClick"
/>
</a-tab-pane>
<template #extra>
<a-button type="text" @click="emptyList">
清空
</a-button>
</template>
</a-tabs>
</a-spin>
</template>
<script lang="ts" setup>
import { computed, reactive, ref, toRefs } from 'vue';
import type { MessageListType, MessageRecord } from './types';
import useLoading from '@/hooks/loading';
import List from './list.vue';
interface TabItem {
key: string;
title: string;
avatar?: string;
}
const { loading, setLoading } = useLoading(true);
const messageType = ref('message');
const messageData = reactive<{
renderList: MessageRecord[];
messageList: MessageRecord[];
}>({
renderList: [],
messageList: [],
});
toRefs(messageData);
const tabList: TabItem[] = [
{
key: 'message',
title: '消息',
},
{
key: 'notice',
title: '通知',
},
{
key: 'todo',
title: '待办',
},
];
async function fetchSourceData() {
setLoading(true);
try {
messageData.messageList = [];
} catch (err) {
// you can report use errorHandler or other
} finally {
setLoading(false);
}
}
async function readMessage(data: MessageListType) {
const ids = data.map((item) => item.id);
messageData.messageList = messageData.messageList.map((item) => ids.includes(item.id) ? { ...item, status: true } : item);
}
const renderList = computed(() => {
return messageData.messageList.filter(
(item) => messageType.value === item.type,
);
});
const unreadCount = computed(() => {
return renderList.value.filter((item) => !item.status).length;
});
const getUnreadList = (type: string) => {
const list = messageData.messageList.filter(
(item) => item.type === type && !item.status,
);
return list;
};
const formatUnreadLength = (type: string) => {
const list = getUnreadList(type);
return list.length ? `(${list.length})` : ``;
};
const handleItemClick = (items: MessageListType) => {
if (renderList.value.length) readMessage([...items]);
};
const emptyList = () => {
messageData.messageList = [];
};
fetchSourceData();
</script>
<style scoped lang="less">
:deep(.arco-popover-popup-content) {
padding: 0;
}
:deep(.arco-list-item-meta) {
align-items: flex-start;
}
:deep(.arco-tabs-nav) {
padding: 14px 0 12px 16px;
border-bottom: 1px solid var(--color-neutral-3);
}
:deep(.arco-tabs-content) {
padding-top: 0;
.arco-result-subtitle {
color: rgb(var(--gray-6));
}
}
</style>

View File

@@ -0,0 +1,149 @@
<template>
<a-list :bordered="false">
<a-list-item
v-for="item in renderList"
:key="item.id"
action-layout="vertical"
:style="{
opacity: item.status ? 0.5 : 1,
}"
>
<template #extra>
<a-tag v-if="item.messageType === 0" color="gray">未开始</a-tag>
<a-tag v-else-if="item.messageType === 1" color="green">已开通</a-tag>
<a-tag v-else-if="item.messageType === 2" color="blue">进行中</a-tag>
<a-tag v-else-if="item.messageType === 3" color="red">即将到期</a-tag>
</template>
<div class="item-wrap" @click="onItemClick(item)">
<a-list-item-meta>
<template v-if="item.avatar" #avatar>
<a-avatar shape="circle">
<img v-if="item.avatar" :src="item.avatar" />
<icon-desktop v-else />
</a-avatar>
</template>
<template #title>
<a-space :size="4">
<span>{{ item.title }}</span>
<a-typography-text type="secondary">
{{ item.subTitle }}
</a-typography-text>
</a-space>
</template>
<template #description>
<div>
<a-typography-paragraph
:ellipsis="{
rows: 1,
}"
>{{ item.content }}</a-typography-paragraph
>
<a-typography-text
v-if="item.type === 'message'"
class="time-text"
>
{{ item.time }}
</a-typography-text>
</div>
</template>
</a-list-item-meta>
</div>
</a-list-item>
<template #footer>
<a-space
fill
:size="0"
:class="{ 'add-border-top': renderList.length < showMax }"
>
<div class="footer-wrap">
<a-link @click="allRead">{{ $t('messageBox.allRead') }}</a-link>
</div>
<div class="footer-wrap">
<a-link>{{ $t('messageBox.viewMore') }}</a-link>
</div>
</a-space>
</template>
<div
v-if="renderList.length && renderList.length < 3"
:style="{ height: (showMax - renderList.length) * 86 + 'px' }"
></div>
</a-list>
</template>
<script lang="ts" setup>
import type { PropType } from 'vue';
import type { MessageListType, MessageRecord } from './types';
const props = defineProps({
renderList: {
type: Array as PropType<MessageListType>,
required: true,
},
unreadCount: {
type: Number,
default: 0,
},
});
const emit = defineEmits(['itemClick']);
const allRead = () => {
emit('itemClick', [...props.renderList]);
};
const onItemClick = (item: MessageRecord) => {
if (!item.status) {
emit('itemClick', [item]);
}
};
const showMax = 3;
</script>
<style scoped lang="less">
:deep(.arco-list) {
.arco-list-item {
min-height: 86px;
border-bottom: 1px solid rgb(var(--gray-3));
}
.arco-list-item-extra {
position: absolute;
right: 20px;
}
.arco-list-item-meta-content {
flex: 1;
}
.item-wrap {
cursor: pointer;
}
.time-text {
font-size: 12px;
color: rgb(var(--gray-6));
}
.arco-empty {
display: none;
}
.arco-list-footer {
padding: 0;
height: 50px;
line-height: 50px;
border-top: none;
.arco-space-item {
width: 100%;
border-right: 1px solid rgb(var(--gray-3));
&:last-child {
border-right: none;
}
}
.add-border-top {
border-top: 1px solid rgb(var(--gray-3));
}
}
.footer-wrap {
text-align: center;
}
.arco-typography {
margin-bottom: 0;
}
.add-border {
border-top: 1px solid rgb(var(--gray-3));
}
}
</style>

View File

@@ -0,0 +1,13 @@
export default {
'messageBox.tab.title.message': 'Message',
'messageBox.tab.title.notice': 'Notice',
'messageBox.tab.title.todo': 'Todo',
'messageBox.tab.button': 'empty',
'messageBox.allRead': 'All Read',
'messageBox.viewMore': 'View More',
'messageBox.noContent': 'No Content',
'messageBox.switchRoles': 'Switch Roles',
'messageBox.userCenter': 'User Center',
'messageBox.userSettings': 'User Settings',
'messageBox.logout': 'Logout',
};

View File

@@ -0,0 +1,13 @@
export default {
'messageBox.tab.title.message': '消息',
'messageBox.tab.title.notice': '通知',
'messageBox.tab.title.todo': '待办',
'messageBox.tab.button': '清空',
'messageBox.allRead': '全部已读',
'messageBox.viewMore': '查看更多',
'messageBox.noContent': '暂无内容',
'messageBox.switchRoles': '切换角色',
'messageBox.userCenter': '用户中心',
'messageBox.userSettings': '用户设置',
'messageBox.logout': '登出登录',
};

View File

@@ -0,0 +1,3 @@
/** 顶部消息展示的本地类型;通知模块接入后由业务 API 替换。 */
export type MessageRecord = { id: string; type: 'message' | 'notice' | 'todo'; status: boolean; title: string; subTitle?: string; content: string; time?: string; avatar?: string; messageType?: number };
export type MessageListType = MessageRecord[];

View File

@@ -0,0 +1,75 @@
<template>
<div class="navbar">
<a-space class="brand">
<img :src="logoUrl" alt="和气平台" />
<a-typography-title :heading="5" :style="{ margin: 0 }">禾气气站管理系统</a-typography-title>
<icon-menu-fold v-if="!topMenu && appStore.device === 'mobile'" class="menu-trigger" @click="toggleDrawerMenu" />
</a-space>
<a-space>
<a-tooltip :content="theme === 'light' ? '切换深色模式' : '切换浅色模式'">
<a-button class="nav-btn" type="outline" shape="circle" @click="() => handleToggleTheme()">
<icon-moon-fill v-if="theme === 'dark'" />
<icon-sun-fill v-else />
</a-button>
</a-tooltip>
<a-tooltip :content="isFullscreen ? '退出全屏' : '进入全屏'">
<a-button class="nav-btn" type="outline" shape="circle" @click="toggleFullScreen">
<icon-fullscreen-exit v-if="isFullscreen" />
<icon-fullscreen v-else />
</a-button>
</a-tooltip>
<a-dropdown trigger="click">
<a-space class="account">
<a-avatar :size="32"><img alt="头像" :src="avatar" /></a-avatar>
<span>{{ userStore.name || '平台管理员' }}</span>
</a-space>
<template #content>
<a-doption @click="handleLogout"><icon-export /> 退出登录</a-doption>
</template>
</a-dropdown>
</a-space>
</div>
</template>
<script setup lang="ts">
import { useDark, useFullscreen, useToggle } from '@vueuse/core';
import { computed, inject } from 'vue';
import logoUrl from '@/assets/logo.svg?url';
import { resolveAvatarUrl } from '@/constants/avatar';
import useUser from '@/hooks/user';
import { useAppStore, useUserStore } from '@/store';
const appStore = useAppStore();
const userStore = useUserStore();
const { logout } = useUser();
const { isFullscreen, toggle: toggleFullScreen } = useFullscreen();
const avatar = computed(() => resolveAvatarUrl(userStore.avatar));
const theme = computed(() => appStore.theme);
const topMenu = computed(() => appStore.topMenu && appStore.menu);
const isDark = useDark({
selector: 'body',
attribute: 'arco-theme',
valueDark: 'dark',
valueLight: 'light',
storageKey: 'arco-theme',
onChanged: (dark) => appStore.toggleTheme(dark),
});
const handleToggleTheme = useToggle(isDark);
const toggleDrawerMenu = inject('toggleDrawerMenu') as () => void;
const handleLogout = () => logout();
</script>
<style scoped lang="less">
.navbar {
display: flex;
align-items: center;
justify-content: space-between;
height: 100%;
padding: 0 20px;
background: var(--color-bg-2);
border-bottom: 1px solid var(--color-border);
}
.brand img { width: 30px; height: 30px; }
.menu-trigger, .account { cursor: pointer; }
.nav-btn { border-color: rgb(var(--gray-2)); color: rgb(var(--gray-8)); }
</style>

View File

@@ -0,0 +1,101 @@
<template>
<div class="tab-bar-container">
<a-affix ref="affixRef" :offset-top="offsetTop">
<div class="tab-bar-box">
<div class="tab-bar-scroll">
<div class="tags-wrap">
<tab-item
v-for="(tag, index) in tagList"
:key="tag.fullPath"
:index="index"
:item-data="tag"
/>
</div>
</div>
<div class="tag-bar-operation"></div>
</div>
</a-affix>
</div>
</template>
<script lang="ts" setup>
import { computed, onUnmounted, ref, watch } from 'vue';
import type { RouteLocationNormalized } from 'vue-router';
import { useAppStore, useTabBarStore } from '@/store';
import {
listenerRouteChange,
removeRouteListener,
} from '@/utils/route-listener';
import tabItem from './tab-item.vue';
const appStore = useAppStore();
const tabBarStore = useTabBarStore();
const affixRef = ref();
const tagList = computed(() => {
return tabBarStore.getTabList;
});
const offsetTop = computed(() => {
return appStore.navbar ? 60 : 0;
});
watch(
() => appStore.navbar,
() => {
affixRef.value.updatePosition();
},
);
listenerRouteChange((route: RouteLocationNormalized) => {
if (
!route.meta.noAffix &&
!tagList.value.some((tag) => tag.fullPath === route.fullPath)
) {
tabBarStore.updateTabList(route);
}
}, true);
onUnmounted(() => {
removeRouteListener();
});
</script>
<style scoped lang="less">
.tab-bar-container {
position: relative;
background-color: var(--color-bg-2);
.tab-bar-box {
display: flex;
padding: 0 0 0 20px;
background-color: var(--color-bg-2);
border-bottom: 1px solid var(--color-border);
.tab-bar-scroll {
height: 32px;
flex: 1;
overflow: hidden;
.tags-wrap {
padding: 4px 0;
height: 48px;
white-space: nowrap;
overflow-x: auto;
:deep(.arco-tag) {
display: inline-flex;
align-items: center;
margin-right: 6px;
cursor: pointer;
&:first-child {
.arco-tag-close-btn {
display: none;
}
}
}
}
}
}
.tag-bar-operation {
width: 100px;
height: 32px;
}
}
</style>

View File

@@ -0,0 +1,12 @@
## 组件说明
该组件非官方最终设计规范,以单独组件存在。
同时仅仅提供最基本的功能,后续进行优化及更改。
## Component description
The component unofficial final design specification exists as a separate component.
At the same time, only the most basic functions are provided, and subsequent optimizations and changes will be made.

View File

@@ -0,0 +1,199 @@
<template>
<a-dropdown
trigger="contextMenu"
:popup-max-height="false"
@select="actionSelect"
>
<span
class="arco-tag arco-tag-size-medium arco-tag-checked"
:class="{ 'link-activated': itemData.fullPath === $route.fullPath }"
@click="goto(itemData)"
>
<span class="tag-link">
{{ itemData.title }}
</span>
<span
class="arco-icon-hover arco-tag-icon-hover arco-icon-hover-size-medium arco-tag-close-btn"
@click.stop="tagClose(itemData, index)"
>
<icon-close />
</span>
</span>
<template #content>
<a-doption :disabled="disabledReload" :value="TabAction.reload">
<icon-refresh />
<span>刷新</span>
</a-doption>
<a-doption
class="sperate-line"
:disabled="disabledCurrent"
:value="TabAction.current"
>
<icon-close />
<span>关闭</span>
</a-doption>
<a-doption :disabled="disabledLeft" :value="TabAction.left">
<icon-to-left />
<span>向左靠</span>
</a-doption>
<a-doption
class="sperate-line"
:disabled="disabledRight"
:value="TabAction.right"
>
<icon-to-right />
<span>向右靠</span>
</a-doption>
<a-doption :value="TabAction.others">
<icon-swap />
<span>其它</span>
</a-doption>
<a-doption :value="TabAction.all">
<icon-folder-delete />
<span>删除</span>
</a-doption>
</template>
</a-dropdown>
</template>
<script lang="ts" setup>
import { computed, type PropType } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { DEFAULT_ROUTE_NAME, REDIRECT_ROUTE_NAME } from '@/router/constants';
import { useTabBarStore } from '@/store';
import type { TagProps } from '@/store/modules/tab-bar/types';
enum TabAction {
reload = 'reload',
current = 'current',
left = 'left',
right = 'right',
others = 'others',
all = 'all',
}
const props = defineProps({
itemData: {
type: Object as PropType<TagProps>,
default() {
return [];
},
},
index: {
type: Number,
default: 0,
},
});
const router = useRouter();
const route = useRoute();
const tabBarStore = useTabBarStore();
const goto = (tag: TagProps) => {
router.push({ ...tag });
};
const tagList = computed(() => {
return tabBarStore.getTabList;
});
const disabledReload = computed(() => {
return props.itemData.fullPath !== route.fullPath;
});
const disabledCurrent = computed(() => {
return props.index === 0;
});
const disabledLeft = computed(() => {
return [0, 1].includes(props.index);
});
const disabledRight = computed(() => {
return props.index === tagList.value.length - 1;
});
const tagClose = (tag: TagProps, idx: number) => {
tabBarStore.deleteTag(idx, tag);
if (props.itemData.fullPath === route.fullPath) {
const latest = tagList.value[idx - 1]; // 获取队列的前一个tab
router.push({ name: latest.name });
}
};
const findCurrentRouteIndex = () => {
return tagList.value.findIndex((el) => el.fullPath === route.fullPath);
};
const actionSelect = async (value: any) => {
const { itemData, index } = props;
const copyTagList = [...tagList.value];
if (value === TabAction.current) {
tagClose(itemData, index);
} else if (value === TabAction.left) {
const currentRouteIdx = findCurrentRouteIndex();
copyTagList.splice(1, props.index - 1);
tabBarStore.freshTabList(copyTagList);
if (currentRouteIdx < index) {
router.push({ name: itemData.name });
}
} else if (value === TabAction.right) {
const currentRouteIdx = findCurrentRouteIndex();
copyTagList.splice(props.index + 1);
tabBarStore.freshTabList(copyTagList);
if (currentRouteIdx > index) {
router.push({ name: itemData.name });
}
} else if (value === TabAction.others) {
const filterList = tagList.value.filter((el, idx) => {
return idx === 0 || idx === props.index;
});
tabBarStore.freshTabList(filterList);
router.push({ name: itemData.name });
} else if (value === TabAction.reload) {
tabBarStore.deleteCache(itemData);
await router.push({
name: REDIRECT_ROUTE_NAME,
params: {
path: route.fullPath,
},
});
tabBarStore.addCache(itemData.name);
} else {
tabBarStore.resetTabList();
router.push({ name: DEFAULT_ROUTE_NAME });
}
};
</script>
<style scoped lang="less">
.tag-link {
color: var(--color-text-2);
text-decoration: none;
}
.link-activated {
color: rgb(var(--link-6));
.tag-link {
color: rgb(var(--link-6));
}
& + .arco-tag-close-btn {
color: rgb(var(--link-6));
}
}
:deep(.arco-dropdown-option-content) {
span {
margin-left: 10px;
}
}
.arco-dropdown-open {
.tag-link {
color: rgb(var(--danger-6));
}
.arco-tag-close-btn {
color: rgb(var(--danger-6));
}
}
.sperate-line {
border-bottom: 1px solid var(--color-neutral-3);
}
</style>

View File

@@ -0,0 +1,16 @@
{
"theme": "light",
"colorWeak": false,
"navbar": true,
"menu": true,
"topMenu": false,
"hideMenu": false,
"menuCollapse": false,
"footer": true,
"themeColor": "#165DFF",
"menuWidth": 220,
"device": "desktop",
"tabBar": false,
"menuFromServer": true,
"serverMenu": []
}

View File

@@ -0,0 +1,14 @@
/** Local default avatar: linear neutral user icon. */
export const DEFAULT_USER_AVATAR = '/avatar-default.svg';
const BROKEN_AVATAR_PATTERN = /pstatp\.com|vcloud\/vadmin/;
export function resolveAvatarUrl(url?: string) {
if (!url || BROKEN_AVATAR_PATTERN.test(url)) {
return DEFAULT_USER_AVATAR;
}
if (url.startsWith('//')) {
return `https:${url}`;
}
return url;
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,8 @@
import type { App } from 'vue';
import permission from './permission';
export default {
install(Vue: App) {
Vue.directive('permission', permission);
},
};

View File

@@ -0,0 +1,30 @@
import type { DirectiveBinding } from 'vue';
import { useUserStore } from '@/store';
function checkPermission(el: HTMLElement, binding: DirectiveBinding) {
const { value } = binding;
const userStore = useUserStore();
const { role } = userStore;
if (Array.isArray(value)) {
if (value.length > 0) {
const permissionValues = value;
const hasPermission = permissionValues.includes(role);
if (!hasPermission && el.parentNode) {
el.parentNode.removeChild(el);
}
}
} else {
throw new Error(`need roles! Like v-permission="['admin','user']"`);
}
}
export default {
mounted(el: HTMLElement, binding: DirectiveBinding) {
checkPermission(el, binding);
},
updated(el: HTMLElement, binding: DirectiveBinding) {
checkPermission(el, binding);
},
};

View File

@@ -0,0 +1,25 @@
import type { EChartsOption } from 'echarts';
import { computed } from 'vue';
import { useAppStore } from '@/store';
// for code hints
// import { SeriesOption } from 'echarts';
// Because there are so many configuration items, this provides a relatively convenient code hint.
// When using vue, pay attention to the reactive issues. It is necessary to ensure that corresponding functions can be triggered, TypeScript does not report errors, and code writing is convenient.
type optionsFn = (isDark: boolean) => EChartsOption;
export default function useChartOption(sourceOption: optionsFn) {
const appStore = useAppStore();
const isDark = computed(() => {
return appStore.theme === 'dark';
});
// echarts support https://echarts.apache.org/zh/theme-builder.html
// It's not used here
// TODO echarts themes
const chartOption = computed<EChartsOption>(() => {
return sourceOption(isDark.value);
});
return {
chartOption,
};
}

View File

@@ -0,0 +1,16 @@
import { ref } from 'vue';
export default function useLoading(initValue = false) {
const loading = ref(initValue);
const setLoading = (value: boolean) => {
loading.value = value;
};
const toggle = () => {
loading.value = !loading.value;
};
return {
loading,
setLoading,
toggle,
};
}

View File

@@ -0,0 +1,38 @@
import type { RouteLocationNormalized, RouteRecordRaw } from 'vue-router';
import { useUserStore } from '@/store';
export default function usePermission() {
const userStore = useUserStore();
return {
accessRouter(route: RouteLocationNormalized | RouteRecordRaw) {
return (
!route.meta?.requiresAuth ||
userStore.role === 'root' ||
(!route.meta?.menuCode && !route.meta?.roles) ||
Boolean(
route.meta?.menuCode &&
userStore.menuCodes.includes(route.meta.menuCode),
) ||
Boolean(route.meta?.roles?.includes(userStore.role))
);
},
findFirstPermissionRoute(_routers: any, role = userStore.role) {
const cloneRouters = [..._routers];
while (cloneRouters.length) {
const firstElement = cloneRouters.shift();
if (
role === 'root' ||
(firstElement?.meta?.menuCode &&
userStore.menuCodes.includes(firstElement.meta.menuCode)) ||
firstElement?.meta?.roles?.includes(role)
)
return { name: firstElement.name };
if (firstElement?.children) {
cloneRouters.push(...firstElement.children);
}
}
return null;
},
// You can add any rules you want
};
}

View File

@@ -0,0 +1,26 @@
import type { AxiosResponse } from 'axios';
import { ref, type UnwrapRef } from 'vue';
import type { HttpResponse } from '@/plugins/http';
import useLoading from './loading';
// use to fetch list
// Don't use async function. It doesn't work in async function.
// Use the bind function to add parameters
// example: useRequest(api.bind(null, {}))
export default function useRequest<T>(
api: () => Promise<AxiosResponse<HttpResponse>>,
defaultValue = [] as unknown as T,
isLoading = true,
) {
const { loading, setLoading } = useLoading(isLoading);
const response = ref<T>(defaultValue);
api()
.then((res) => {
response.value = res.data as unknown as UnwrapRef<T>;
})
.finally(() => {
setLoading(false);
});
return { loading, response };
}

View File

@@ -0,0 +1,32 @@
import { useDebounceFn } from '@vueuse/core';
import { onBeforeMount, onBeforeUnmount, onMounted } from 'vue';
import { useAppStore } from '@/store';
import { addEventListen, removeEventListen } from '@/utils/event';
const WIDTH = 992; // https://arco.design/vue/component/grid#responsivevalue
function queryDevice() {
const rect = document.body.getBoundingClientRect();
return rect.width - 1 < WIDTH;
}
export default function useResponsive(immediate?: boolean) {
const appStore = useAppStore();
function resizeHandler() {
if (!document.hidden) {
const isMobile = queryDevice();
appStore.toggleDevice(isMobile ? 'mobile' : 'desktop');
appStore.toggleMenu(isMobile);
}
}
const debounceFn = useDebounceFn(resizeHandler, 100);
onMounted(() => {
if (immediate) debounceFn();
});
onBeforeMount(() => {
addEventListen(window, 'resize', debounceFn);
});
onBeforeUnmount(() => {
removeEventListen(window, 'resize', debounceFn);
});
}

View File

@@ -0,0 +1,12 @@
import { computed } from 'vue';
import { useAppStore } from '@/store';
export default function useThemes() {
const appStore = useAppStore();
const isDark = computed(() => {
return appStore.theme === 'dark';
});
return {
isDark,
};
}

View File

@@ -0,0 +1,24 @@
import { Message } from '@arco-design/web-vue';
import { useRouter } from 'vue-router';
import { useUserStore } from '@/store';
export default function useUser() {
const router = useRouter();
const userStore = useUserStore();
const logout = async (logoutTo?: string) => {
await userStore.logout();
const currentRoute = router.currentRoute.value;
Message.success('登出成功');
router.push({
name: logoutTo && typeof logoutTo === 'string' ? logoutTo : 'login',
query: {
...router.currentRoute.value.query,
redirect: currentRoute.name as string,
},
});
};
return {
logout,
};
}

View File

@@ -0,0 +1,178 @@
<template>
<a-layout class="layout" :class="{ mobile: appStore.hideMenu }">
<div v-if="navbar" class="layout-navbar">
<NavBar />
</div>
<a-layout>
<a-layout>
<a-layout-sider
v-if="renderMenu"
v-show="!hideMenu"
class="layout-sider"
breakpoint="xl"
:collapsed="collapsed"
:collapsible="true"
:width="menuWidth"
:style="{ paddingTop: navbar ? '60px' : '' }"
:hide-trigger="true"
@collapse="setCollapsed"
>
<div class="menu-wrapper">
<Menu />
</div>
</a-layout-sider>
<a-drawer
v-if="hideMenu"
:visible="drawerVisible"
placement="left"
:footer="false"
mask-closable
:closable="false"
@cancel="drawerCancel"
>
<Menu />
</a-drawer>
<a-layout class="layout-content" :style="paddingStyle">
<TabBar v-if="appStore.tabBar" />
<a-layout-content>
<PageLayout />
</a-layout-content>
<Footer v-if="footer" />
</a-layout>
</a-layout>
</a-layout>
</a-layout>
</template>
<script lang="ts" setup>
import { computed, onMounted, provide, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import Footer from '@/components/footer/index.vue';
import Menu from '@/components/menu/index.vue';
import NavBar from '@/components/navbar/index.vue';
import TabBar from '@/components/tab-bar/index.vue';
import usePermission from '@/hooks/permission';
import useResponsive from '@/hooks/responsive';
import { useAppStore, useUserStore } from '@/store';
import PageLayout from './page-layout.vue';
const isInit = ref(false);
const appStore = useAppStore();
const userStore = useUserStore();
const router = useRouter();
const route = useRoute();
const permission = usePermission();
useResponsive(true);
const navbarHeight = `60px`;
const navbar = computed(() => appStore.navbar);
const renderMenu = computed(() => appStore.menu && !appStore.topMenu);
const hideMenu = computed(() => appStore.hideMenu);
const footer = computed(() => appStore.footer);
const menuWidth = computed(() => {
return appStore.menuCollapse ? 48 : appStore.menuWidth;
});
const collapsed = computed(() => {
return appStore.menuCollapse;
});
const paddingStyle = computed(() => {
const paddingLeft =
renderMenu.value && !hideMenu.value
? { paddingLeft: `${menuWidth.value}px` }
: {};
const paddingTop = navbar.value ? { paddingTop: navbarHeight } : {};
return { ...paddingLeft, ...paddingTop };
});
const setCollapsed = (val: boolean) => {
if (!isInit.value) return; // for page initialization menu state problem
appStore.updateSettings({ menuCollapse: val });
};
watch(
() => userStore.role,
(roleValue) => {
if (roleValue && !permission.accessRouter(route))
router.push({ name: 'notFound' });
},
);
const drawerVisible = ref(false);
const drawerCancel = () => {
drawerVisible.value = false;
};
provide('toggleDrawerMenu', () => {
drawerVisible.value = !drawerVisible.value;
});
onMounted(() => {
isInit.value = true;
});
</script>
<style scoped lang="less">
@nav-size-height: 60px;
@layout-max-width: 1100px;
.layout {
width: 100%;
height: 100%;
}
.layout-navbar {
position: fixed;
top: 0;
left: 0;
z-index: 100;
width: 100%;
height: @nav-size-height;
}
.layout-sider {
position: fixed;
top: 0;
left: 0;
z-index: 99;
height: 100%;
transition: all 0.2s cubic-bezier(0.34, 0.69, 0.1, 1);
&::after {
position: absolute;
top: 0;
right: -1px;
display: block;
width: 1px;
height: 100%;
background-color: var(--color-border);
content: '';
}
> :deep(.arco-layout-sider-children) {
overflow-y: hidden;
}
}
.menu-wrapper {
height: 100%;
overflow: auto;
overflow-x: hidden;
:deep(.arco-menu) {
::-webkit-scrollbar {
width: 12px;
height: 4px;
}
::-webkit-scrollbar-thumb {
border: 4px solid transparent;
background-clip: padding-box;
border-radius: 7px;
background-color: var(--color-text-4);
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--color-text-3);
}
}
}
.layout-content {
min-height: 100vh;
overflow-y: hidden;
background-color: var(--color-fill-2);
transition: padding 0.2s cubic-bezier(0.34, 0.69, 0.1, 1);
}
</style>

View File

@@ -0,0 +1,23 @@
<template>
<router-view v-slot="{ Component, route }">
<component
:is="Component"
v-if="route.meta.ignoreCache"
:key="route.fullPath"
/>
<keep-alive v-else :include="cacheList">
<component :is="Component" :key="route.fullPath" />
</keep-alive>
</router-view>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { useTabBarStore } from '@/store';
const tabBarStore = useTabBarStore();
const cacheList = computed(() => tabBarStore.getCacheList);
</script>
<style scoped lang="less"></style>

View File

@@ -0,0 +1,88 @@
import Mock from 'mockjs';
import setupMock, { successResponseWrap } from '@/mocks/setup';
const haveReadIds: number[] = [];
const getMessageList = () => {
return [
{
id: 1,
type: 'message',
title: '\u90d1\u6666\u6708',
subTitle: '\u7684\u79c1\u4fe1',
avatar:
'//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/8361eeb82904210b4f55fab888fe8416.png~tplv-uwbnlip3yd-webp.webp',
content: '\u5ba1\u6279\u8bf7\u6c42\u5df2\u53d1\u9001\uff0c\u8bf7\u67e5\u6536',
time: '\u4eca\u5929 12:30:01',
},
{
id: 2,
type: 'message',
title: '\u5b81\u6ce2',
subTitle: '\u7684\u56de\u590d',
avatar:
'//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/3ee5f13fb09879ecb5185e440cef6eb9.png~tplv-uwbnlip3yd-webp.webp',
content: '\u6b64\u5904 bug \u5df2\u7ecf\u4fee\u590d',
time: '\u4eca\u5929 12:30:01',
},
{
id: 3,
type: 'message',
title: '\u5b81\u6ce2',
subTitle: '\u7684\u56de\u590d',
avatar:
'//p1-arco.byteimg.com/tos-cn-i-uwbnlip3yd/3ee5f13fb09879ecb5185e440cef6eb9.png~tplv-uwbnlip3yd-webp.webp',
content: '\u6b64\u5904 bug \u5df2\u7ecf\u4fee\u590d',
time: '\u4eca\u5929 12:20:01',
},
{
id: 4,
type: 'notice',
title: '\u7eed\u8d39\u901a\u77e5',
subTitle: '',
avatar: '',
content:
'\u60a8\u7684\u4ea7\u54c1\u4f7f\u7528\u671f\u9650\u5373\u5c06\u622a\u6b62\uff0c\u5982\u9700\u7ee7\u7eed\u4f7f\u7528\u4ea7\u54c1\u8bf7\u524d\u5f80\u8d2d\u4e70',
time: '\u4eca\u5929 12:20:01',
messageType: 3,
},
{
id: 5,
type: 'notice',
title: '\u89c4\u5219\u5f00\u901a\u6210\u529f',
subTitle: '',
avatar: '',
content:
'\u5185\u5bb9\u5c4f\u853d\u89c4\u5219\u4e8e 2021-12-01 \u5f00\u901a\u6210\u529f\u5e76\u751f\u6548',
time: '\u4eca\u5929 12:20:01',
messageType: 1,
},
{
id: 6,
type: 'todo',
title: '\u8d28\u68c0\u961f\u5217\u53d8\u66f4',
subTitle: '',
avatar: '',
content:
'\u5185\u5bb9\u8d28\u68c0\u961f\u5217\u4e8e 2021-12-01 19:50:23 \u8fdb\u884c\u53d8\u66f4\uff0c\u8bf7\u91cd\u65b0\u63d0\u4ea4',
time: '\u4eca\u5929 12:20:01',
messageType: 0,
},
].map((item) => ({
...item,
status: haveReadIds.indexOf(item.id) === -1 ? 0 : 1,
}));
};
setupMock({
setup: () => {
Mock.mock(/\/api\/message\/list/, () => {
return successResponseWrap(getMessageList());
});
Mock.mock(/\/api\/message\/read/, (params: { body: string }) => {
const { ids } = JSON.parse(params.body);
haveReadIds.push(...(ids || []));
return successResponseWrap(true);
});
},
});

View File

@@ -0,0 +1,104 @@
import Mock from 'mockjs';
import { DEFAULT_USER_AVATAR } from '@/constants/avatar';
import { isLogin } from '@/utils/auth';
import setupMock, {
failResponseWrap,
successResponseWrap,
} from '@/mocks/setup';
export interface MockParams {
url: string;
type: string;
body: string;
}
setupMock({
setup() {
Mock.mock(/\/api\/user\/info/, () => {
if (isLogin()) {
const role = window.localStorage.getItem('userRole') || 'admin';
return successResponseWrap({
name: 'admin',
avatar: DEFAULT_USER_AVATAR,
email: 'wangliqun@email.com',
job: 'frontend',
jobName: '\u524d\u7aef\u827a\u672f\u5bb6',
organization: 'Frontend',
organizationName: '\u524d\u7aef',
location: 'beijing',
locationName: '\u5317\u4eac',
introduction: '\u4eba\u7206\u723d\uff0c\u6027\u6e29\u7eaf',
personalWebsite: 'https://www.arco.design',
phone: '150****0000',
registrationDate: '2013-05-10 12:10:00',
accountId: '15012312300',
certification: 1,
role,
});
}
return failResponseWrap(null, '\u672a\u767b\u5f55', 50008);
});
Mock.mock(/\/api\/user\/login/, (params: MockParams) => {
const { username, password } = JSON.parse(params.body);
if (!username) {
return failResponseWrap(
null,
'\u7528\u6237\u540d\u4e0d\u80fd\u4e3a\u7a7a',
50000,
);
}
if (!password) {
return failResponseWrap(null, '\u5bc6\u7801\u4e0d\u80fd\u4e3a\u7a7a', 50000);
}
if (username === 'admin' && password === 'admin') {
window.localStorage.setItem('userRole', 'admin');
return successResponseWrap({ token: '12345' });
}
if (username === 'user' && password === 'user') {
window.localStorage.setItem('userRole', 'user');
return successResponseWrap({ token: '54321' });
}
return failResponseWrap(
null,
'\u8d26\u53f7\u6216\u8005\u5bc6\u7801\u9519\u8bef',
50000,
);
});
Mock.mock(/\/api\/user\/logout/, () => successResponseWrap(null));
Mock.mock(/\/api\/user\/menu/, () => {
const menuList = [
{
path: '/dashboard',
name: 'dashboard',
meta: {
locale: 'menu.server.dashboard',
requiresAuth: true,
icon: 'icon-dashboard',
order: 1,
},
children: [
{
path: 'workplace',
name: 'Workplace',
meta: {
locale: 'menu.server.workplace',
requiresAuth: true,
},
},
{
path: 'https://arco.design',
name: 'arcoWebsite',
meta: {
locale: 'menu.arcoWebsite',
requiresAuth: true,
},
},
],
},
];
return successResponseWrap(menuList);
});
},
});

View File

@@ -0,0 +1,9 @@
import Mock from 'mockjs';
import './handlers/user';
import './handlers/message-box';
import.meta.glob('@/views/**/mock.ts', { eager: true });
Mock.setup({
timeout: '600-1000',
});

View File

@@ -0,0 +1,27 @@
import debug from '@/utils/env';
export default ({ mock, setup }: { mock?: boolean; setup: () => void }) => {
if (mock !== false && debug) setup();
};
export const successResponseWrap = (data: unknown) => {
return {
data,
status: 'ok',
msg: '请求成功',
code: 20000,
};
};
export const failResponseWrap = (
data: unknown,
msg: string,
code = 50000,
) => {
return {
data,
status: 'fail',
msg,
code,
};
};

View File

@@ -0,0 +1,73 @@
import { Message, Modal } from '@arco-design/web-vue';
import type { AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import axios from 'axios';
import { useUserStore } from '@/store';
import { getToken } from '@/utils/auth';
export interface HttpResponse<T = unknown> {
status: number;
msg: string;
code: number;
data: T;
}
let initialized = false;
export function setupHttp() {
if (initialized) {
return;
}
initialized = true;
const baseURL = import.meta.env.VITE_API_BASE_URL?.trim();
if (baseURL) {
axios.defaults.baseURL = baseURL;
}
axios.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = getToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error),
);
axios.interceptors.response.use(
(response: AxiosResponse<HttpResponse>) => {
const res = response.data;
if (res.code !== 20000) {
Message.error({
content: res.msg || '请求失败',
duration: 5 * 1000,
});
if (
[50008, 50012, 50014].includes(res.code) &&
response.config.url !== '/api/user/info'
) {
Modal.error({
title: '登录已失效',
content: '当前登录状态已失效,请重新登录。',
okText: '重新登录',
async onOk() {
const userStore = useUserStore();
await userStore.logout();
window.location.reload();
},
});
}
return Promise.reject(new Error(res.msg || '请求失败'));
}
return res as unknown as AxiosResponse<HttpResponse>;
},
(error) => {
Message.error({
content: error.msg || '网络请求失败',
duration: 5 * 1000,
});
return Promise.reject(error);
},
);
}

View File

@@ -0,0 +1,16 @@
import { appExternalRoutes, appRoutes } from '../routes';
const mixinRoutes = [...appRoutes, ...appExternalRoutes];
const appClientMenus = mixinRoutes.map((el) => {
const { name, path, meta, redirect, children } = el;
return {
name,
path,
meta,
redirect,
children,
};
});
export default appClientMenus;

View File

@@ -0,0 +1,18 @@
export const WHITE_LIST = [
{ name: 'notFound', children: [] },
{ name: 'login', children: [] },
];
export const NOT_FOUND = {
name: 'notFound',
};
export const REDIRECT_ROUTE_NAME = 'Redirect';
export const DEFAULT_ROUTE_NAME = 'Workplace';
export const DEFAULT_ROUTE = {
title: 'menu.dashboard.workplace',
name: DEFAULT_ROUTE_NAME,
fullPath: '/dashboard/workplace',
};

View File

@@ -0,0 +1,17 @@
import type { Router } from 'vue-router';
import { setRouteEmitter } from '@/utils/route-listener';
import setupPermissionGuard from './permission';
import setupUserLoginInfoGuard from './userLoginInfo';
function setupPageGuard(router: Router) {
router.beforeEach(async (to) => {
// emit route change
setRouteEmitter(to);
});
}
export default function createRouteGuard(router: Router) {
setupPageGuard(router);
setupUserLoginInfoGuard(router);
setupPermissionGuard(router);
}

View File

@@ -0,0 +1,53 @@
import NProgress from 'nprogress'; // progress bar
import type { RouteRecordNormalized, Router } from 'vue-router';
import usePermission from '@/hooks/permission';
import { useAppStore, useUserStore } from '@/store';
import { NOT_FOUND, WHITE_LIST } from '../constants';
import { appRoutes } from '../routes';
export default function setupPermissionGuard(router: Router) {
router.beforeEach(async (to, from, next) => {
const appStore = useAppStore();
const userStore = useUserStore();
const Permission = usePermission();
const permissionsAllow = Permission.accessRouter(to);
if (appStore.menuFromServer) {
// 针对来自服务端的菜单配置进行处理
// Handle routing configuration from the server
// 根据需要自行完善来源于服务端的菜单配置的permission逻辑
// Refine the permission logic from the server's menu configuration as needed
if (
!appStore.appAsyncMenus.length &&
!WHITE_LIST.find((el) => el.name === to.name)
) {
await appStore.fetchServerMenuConfig();
}
const serverMenuConfig = [...appStore.appAsyncMenus, ...WHITE_LIST];
let exist = false;
while (serverMenuConfig.length && !exist) {
const element = serverMenuConfig.shift();
if (element?.name === to.name) exist = true;
if (element?.children) {
serverMenuConfig.push(
...(element.children as unknown as RouteRecordNormalized[]),
);
}
}
if (exist && permissionsAllow) {
next();
} else next(NOT_FOUND);
} else if (permissionsAllow) {
next();
} else {
const destination =
Permission.findFirstPermissionRoute(appRoutes, userStore.role) ||
NOT_FOUND;
next(destination);
}
NProgress.done();
});
}

View File

@@ -0,0 +1,44 @@
import NProgress from 'nprogress'; // progress bar
import type { LocationQueryRaw, Router } from 'vue-router';
import { useUserStore } from '@/store';
import { isLogin } from '@/utils/auth';
export default function setupUserLoginInfoGuard(router: Router) {
router.beforeEach(async (to, _from, next) => {
NProgress.start();
const userStore = useUserStore();
// 登录页不需要校验旧令牌,避免后端未启动或令牌过期时重复请求用户资料。
if (to.name === 'login') {
next();
return;
}
if (isLogin()) {
if (userStore.role) {
next();
} else {
try {
await userStore.info();
next();
} catch {
await userStore.logout();
next({
name: 'login',
query: {
redirect: to.name,
...to.query,
} as LocationQueryRaw,
});
}
}
} else {
next({
name: 'login',
query: {
redirect: to.name,
...to.query,
} as LocationQueryRaw,
});
}
});
}

View File

@@ -0,0 +1,37 @@
import NProgress from 'nprogress'; // progress bar
import { createRouter, createWebHistory } from 'vue-router';
import 'nprogress/nprogress.css';
import createRouteGuard from './guard';
import { appRoutes } from './routes';
import { NOT_FOUND_ROUTE, REDIRECT_MAIN } from './routes/base';
NProgress.configure({ showSpinner: false }); // NProgress Configuration
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
redirect: 'login',
},
{
path: '/login',
name: 'login',
component: () => import('@/views/login/index.vue'),
meta: {
requiresAuth: false,
},
},
...appRoutes,
REDIRECT_MAIN,
NOT_FOUND_ROUTE,
],
scrollBehavior() {
return { top: 0 };
},
});
createRouteGuard(router);
export default router;

View File

@@ -0,0 +1,31 @@
import type { RouteRecordRaw } from 'vue-router';
import { REDIRECT_ROUTE_NAME } from '@/router/constants';
export const DEFAULT_LAYOUT = () => import('@/layout/default-layout.vue');
export const REDIRECT_MAIN: RouteRecordRaw = {
path: '/redirect',
name: 'redirectWrapper',
component: DEFAULT_LAYOUT,
meta: {
requiresAuth: true,
hideInMenu: true,
},
children: [
{
path: '/redirect/:path',
name: REDIRECT_ROUTE_NAME,
component: () => import('@/views/redirect/index.vue'),
meta: {
requiresAuth: true,
hideInMenu: true,
},
},
],
};
export const NOT_FOUND_ROUTE: RouteRecordRaw = {
path: '/:pathMatch(.*)*',
name: 'notFound',
component: () => import('@/views/not-found/index.vue'),
};

View File

@@ -0,0 +1,10 @@
export default {
path: 'https://arco.design',
name: 'arcoWebsite',
meta: {
locale: 'menu.arcoWebsite',
icon: 'icon-link',
requiresAuth: true,
order: 8,
},
};

View File

@@ -0,0 +1,10 @@
export default {
path: 'https://arco.design/vue/docs/pro/faq',
name: 'faq',
meta: {
locale: 'menu.faq',
icon: 'icon-question-circle',
requiresAuth: true,
order: 9,
},
};

View File

@@ -0,0 +1,8 @@
import type { RouteRecordNormalized } from 'vue-router';
import platformRoutes from './modules/platform';
/** 气站后台仅注册已实现的业务路由,菜单能力由后端返回。 */
export const appRoutes: RouteRecordNormalized[] = platformRoutes as unknown as RouteRecordNormalized[];
/** 本期没有外部菜单。 */
export const appExternalRoutes: RouteRecordNormalized[] = [];

View File

@@ -0,0 +1,102 @@
import { DEFAULT_LAYOUT } from '../base';
import type { AppRouteRecordRaw } from '../types';
const resourcePage = () => import('@/views/shared/ResourcePage.vue');
function child(
domain: string,
path: string,
title: string,
resource: string,
menuCode: string,
meta: Record<string, unknown> = {},
): AppRouteRecordRaw {
return {
path,
name: `${domain}-${path}`,
component: resourcePage,
meta: { title, resource, requiresAuth: true, menuCode, ...meta },
};
}
function group(
path: string,
name: string,
title: string,
icon: string,
order: number,
children: AppRouteRecordRaw[],
): AppRouteRecordRaw {
return {
path: `/${path}`,
name,
component: DEFAULT_LAYOUT,
redirect: `/${path}/${children[0].path}`,
meta: { title, requiresAuth: true, icon, order, menuCode: name },
children,
};
}
const routes: AppRouteRecordRaw[] = [
{
path: '/dashboard',
name: 'dashboard',
component: DEFAULT_LAYOUT,
redirect: '/dashboard/overview',
meta: { title: '数据概述', requiresAuth: true, icon: 'icon-dashboard', order: 10, menuCode: 'dashboard' },
children: [
{ path: 'overview', name: 'dashboard-overview', component: () => import('@/views/dashboard/DashboardPage.vue'), meta: { title: '运营概览', requiresAuth: true, menuCode: 'dashboard_overview' } },
{ path: 'reports', name: 'dashboard-reports', component: () => import('@/views/dashboard/ReportPage.vue'), meta: { title: '统计报表', requiresAuth: true, menuCode: 'dashboard_reports' } },
],
},
group('delivery', 'delivery', '配送点管理', 'icon-storage', 20, [
child('delivery', 'points', '配送点列表', '/delivery_basic', 'delivery_basic'),
child('delivery', 'accounts', '配送点账户', '/delivery_account', 'delivery_basic', { hideInMenu: true, activeMenu: 'delivery-points' }),
]),
group('staff', 'staff', '工作人员管理', 'icon-user-group', 30, [
child('staff', 'add', '新增工作人员', '/staff_account', 'staff_add', { createMode: true }),
child('staff', 'installers', '安装人员', '/staff_account', 'staff_installer', { staffType: 'installer' }),
child('staff', 'delivery', '配送人员', '/staff_account', 'staff_delivery', { staffType: 'delivery' }),
child('staff', 'operations', '运维人员', '/staff_account', 'staff_operations', { staffType: 'operations' }),
child('staff', 'credentials', '人员资质', '/staff_credential', 'staff', { hideInMenu: true, activeMenu: 'staff-installers' }),
]),
group('user', 'user', '用户管理', 'icon-user', 40, [
child('user', 'accounts', '用户账户', '/user_account', 'user_account'),
child('user', 'addresses', '用户地址', '/user_address', 'user_account', { hideInMenu: true, activeMenu: 'user-accounts' }),
]),
group('contract', 'contract', '合同管理', 'icon-file', 50, [
child('contract', 'contracts', '配送合同', '/gasorder_contract', 'gasorder_contract'),
child('contract', 'products', '合同气瓶', '/gasorder_contract_product', 'gasorder_contract', { hideInMenu: true, activeMenu: 'contract-contracts' }),
child('contract', 'revisions', '合同修订记录', '/gasorder_contract_revision', 'gasorder_contract', { hideInMenu: true, activeMenu: 'contract-contracts' }),
child('contract', 'products-candidates', '合同可选气瓶', '/product_info', 'gasorder_contract', { hideInMenu: true, activeMenu: 'contract-contracts' }),
]),
group('gasorder', 'gasorder', '燃气配送订单', 'icon-list', 60, [
child('gasorder', 'create', '创建订单', '/gasorder_basic', 'gasorder_create', { createMode: true }),
child('gasorder', 'orders', '配送订单', '/gasorder_basic', 'gasorder_basic'),
]),
group('finance', 'finance', '财务管理', 'icon-bar-chart', 70, [
child('finance', 'wallet', '钱包', '/wallet_basic', 'wallet_basic'),
child('finance', 'banks', '银行卡', '/wallet_bank', 'wallet_bank'),
child('finance', 'payments', '支付记录', '/wallet_payment', 'wallet_payment'),
child('finance', 'records', '钱包流水', '/wallet_record', 'wallet_record'),
child('finance', 'refunds', '退款记录', '/wallet_refund', 'wallet_refund'),
child('finance', 'withdrawals', '提现申请', '/wallet_apply_cash', 'wallet_apply_cash'),
child('finance', 'settlements', '财务结算', '/fin_settlement', 'fin_settlement'),
child('finance', 'reconciliations', '财务对账', '/fin_reconciliation', 'fin_reconciliation'),
]),
group('ticket', 'ticket', '工单管理', 'icon-customer-service', 80, [
child('ticket', 'tickets', '客服工单', '/cs_ticket', 'cs_ticket'),
]),
{
path: '/invitation',
name: 'invitation',
component: DEFAULT_LAYOUT,
redirect: '/invitation/qrcode',
meta: { title: '邀请注册', requiresAuth: true, icon: 'icon-qrcode', order: 90, menuCode: 'invitation' },
children: [
{ path: 'qrcode', name: 'invitation-qrcode', component: () => import('@/views/invitation/QrcodePage.vue'), meta: { title: '邀请二维码', requiresAuth: true, menuCode: 'invitation_qrcode' } },
],
},
];
export default routes;

View File

@@ -0,0 +1,20 @@
import type { defineComponent } from 'vue';
import type { NavigationGuard, RouteMeta } from 'vue-router';
export type Component<T = any> =
| ReturnType<typeof defineComponent>
| (() => Promise<typeof import('*.vue')>)
| (() => Promise<T>);
export interface AppRouteRecordRaw {
path: string;
name?: string | symbol;
meta?: RouteMeta;
redirect?: string;
component: Component | string;
children?: AppRouteRecordRaw[];
alias?: string | string[];
props?: Record<string, any>;
beforeEnter?: NavigationGuard | NavigationGuard[];
fullPath?: string;
}

View File

@@ -0,0 +1,19 @@
import 'vue-router';
declare module 'vue-router' {
interface RouteMeta {
roles?: string[]; // Controls roles that have access to the page
menuCode?: string; // Server-assigned menu domain required by this route
staffType?: 'installer' | 'delivery' | 'operations';
createMode?: boolean;
requiresAuth: boolean; // Whether login is required to access the current page (every route must declare)
icon?: string; // The icon show in the side menu
locale?: string; // The locale name show in side menu and breadcrumb
hideInMenu?: boolean; // If true, it is not displayed in the side menu
hideChildrenInMenu?: boolean; // if set true, the children are not displayed in the side menu
activeMenu?: string; // if set name, the menu will be highlighted according to the name you set
order?: number; // Sort routing menu items. If set key, the higher the value, the more forward it is
noAffix?: boolean; // if set true, the tag will not affix in the tab-bar
ignoreCache?: boolean; // if set true, the page will not be cached
}
}

View File

@@ -0,0 +1,9 @@
import { createPinia } from 'pinia';
import useAppStore from './modules/app';
import useTabBarStore from './modules/tab-bar';
import useUserStore from './modules/user';
const pinia = createPinia();
export { useAppStore, useTabBarStore, useUserStore };
export default pinia;

View File

@@ -0,0 +1,129 @@
import { Notification } from '@arco-design/web-vue';
import { defineStore } from 'pinia';
import type { RouteRecordNormalized, RouteRecordRaw } from 'vue-router';
import { platformApi, type PlatformMenu } from '@/api/platform';
import defaultSettings from '@/config/settings.json';
import appClientMenus from '@/router/app-menus';
import type { AppState } from './types';
function indexClientMenus(
routes: RouteRecordRaw[],
index = new Map<string, RouteRecordRaw>(),
) {
for (const route of routes) {
const menuCode = route.meta?.menuCode;
if (menuCode && route.meta?.hideInMenu !== true && !index.has(menuCode)) {
index.set(menuCode, route);
}
if (route.children) indexClientMenus(route.children, index);
}
return index;
}
function buildServerMenuTree(menus: PlatformMenu[]): RouteRecordRaw[] {
const clientMenus = indexClientMenus(
appClientMenus as unknown as RouteRecordRaw[],
);
const nodes = new Map<string, RouteRecordRaw>();
for (const menu of [...menus].sort((a, b) => a.sort_no - b.sort_no)) {
const clientRoute = clientMenus.get(menu.identity);
if (!clientRoute) continue;
nodes.set(menu.identity, {
...clientRoute,
path: menu.path,
meta: {
...clientRoute.meta,
title: menu.name,
icon: menu.icon || clientRoute.meta?.icon,
order: menu.sort_no,
menuCode: menu.identity,
requiresAuth: clientRoute.meta?.requiresAuth ?? true,
},
children: [],
});
}
const roots: RouteRecordRaw[] = [];
for (const menu of menus) {
const node = nodes.get(menu.identity);
if (!node) continue;
if (menu.parent_identity) {
const parent = nodes.get(menu.parent_identity);
if (parent) parent.children?.push(node);
} else {
roots.push(node);
}
}
return roots;
}
const useAppStore = defineStore('app', {
state: (): AppState => ({ ...defaultSettings }),
getters: {
appCurrentSetting(state: AppState): AppState {
return { ...state };
},
appDevice(state: AppState) {
return state.device;
},
appAsyncMenus(state: AppState): RouteRecordNormalized[] {
return state.serverMenu as unknown as RouteRecordNormalized[];
},
},
actions: {
// Update app settings
updateSettings(partial: Partial<AppState>) {
// @ts-expect-error-next-line
this.$patch(partial);
},
// Change theme color
toggleTheme(dark: boolean) {
if (dark) {
this.theme = 'dark';
document.body.setAttribute('arco-theme', 'dark');
} else {
this.theme = 'light';
document.body.removeAttribute('arco-theme');
}
},
toggleDevice(device: string) {
this.device = device;
},
toggleMenu(value: boolean) {
this.hideMenu = value;
},
async fetchServerMenuConfig() {
try {
Notification.info({
id: 'menuNotice', // Keep the instance id the same
content: 'loading',
closable: true,
});
const response = await platformApi.listMenu();
this.serverMenu = buildServerMenuTree(
response.list,
) as unknown as RouteRecordNormalized[];
Notification.success({
id: 'menuNotice',
content: 'success',
closable: true,
});
} catch {
Notification.error({
id: 'menuNotice',
content: 'error',
closable: true,
});
}
},
clearServerMenu() {
this.serverMenu = [];
},
},
});
export default useAppStore;

View File

@@ -0,0 +1,19 @@
import type { RouteRecordNormalized } from 'vue-router';
export interface AppState {
theme: string;
colorWeak: boolean;
navbar: boolean;
menu: boolean;
topMenu: boolean;
hideMenu: boolean;
menuCollapse: boolean;
footer: boolean;
themeColor: string;
menuWidth: number;
device: string;
tabBar: boolean;
menuFromServer: boolean;
serverMenu: RouteRecordNormalized[];
[key: string]: unknown;
}

View File

@@ -0,0 +1,75 @@
import { defineStore } from 'pinia';
import type { RouteLocationNormalized } from 'vue-router';
import {
DEFAULT_ROUTE,
DEFAULT_ROUTE_NAME,
REDIRECT_ROUTE_NAME,
} from '@/router/constants';
import { isString } from '@/utils/is';
import type { TabBarState, TagProps } from './types';
const formatTag = (route: RouteLocationNormalized): TagProps => {
const { name, meta, fullPath, query } = route;
return {
title: meta.locale || '',
name: String(name),
fullPath,
query,
ignoreCache: meta.ignoreCache,
};
};
const BAN_LIST = [REDIRECT_ROUTE_NAME];
const useAppStore = defineStore('tabBar', {
state: (): TabBarState => ({
cacheTabList: new Set([DEFAULT_ROUTE_NAME]),
tagList: [DEFAULT_ROUTE],
}),
getters: {
getTabList(): TagProps[] {
return this.tagList;
},
getCacheList(): string[] {
return Array.from(this.cacheTabList);
},
},
actions: {
updateTabList(route: RouteLocationNormalized) {
if (BAN_LIST.includes(route.name as string)) return;
this.tagList.push(formatTag(route));
if (!route.meta.ignoreCache) {
this.cacheTabList.add(route.name as string);
}
},
deleteTag(idx: number, tag: TagProps) {
this.tagList.splice(idx, 1);
this.cacheTabList.delete(tag.name);
},
addCache(name: string) {
if (isString(name) && name !== '') this.cacheTabList.add(name);
},
deleteCache(tag: TagProps) {
this.cacheTabList.delete(tag.name);
},
freshTabList(tags: TagProps[]) {
this.tagList = tags;
this.cacheTabList.clear();
// 要先判断ignoreCache
for (const name of this.tagList
.filter((el) => !el.ignoreCache)
.map((el) => el.name)) {
this.cacheTabList.add(name);
}
},
resetTabList() {
this.tagList = [DEFAULT_ROUTE];
this.cacheTabList.clear();
this.cacheTabList.add(DEFAULT_ROUTE_NAME);
},
},
});
export default useAppStore;

View File

@@ -0,0 +1,12 @@
export interface TagProps {
title: string;
name: string;
fullPath: string;
query?: any;
ignoreCache?: boolean;
}
export interface TabBarState {
tagList: TagProps[];
cacheTabList: Set<string>;
}

View File

@@ -0,0 +1,100 @@
import { defineStore } from 'pinia';
import { authApi, type LoginData } from '@/api/auth';
import { resolveAvatarUrl } from '@/constants/avatar';
import { clearToken, setToken } from '@/utils/auth';
import { removeRouteListener } from '@/utils/route-listener';
import useAppStore from '../app';
import type { UserState } from './types';
const useUserStore = defineStore('user', {
state: (): UserState => ({
name: undefined,
avatar: undefined,
job: undefined,
organization: undefined,
location: undefined,
email: undefined,
introduction: undefined,
personalWebsite: undefined,
jobName: undefined,
organizationName: undefined,
locationName: undefined,
phone: undefined,
registrationDate: undefined,
accountId: undefined,
certification: undefined,
role: '',
menuCodes: [],
}),
getters: {
userInfo(state: UserState): UserState {
return { ...state };
},
},
actions: {
switchRoles() {
return new Promise((resolve) => {
this.role = this.role === 'user' ? 'admin' : 'user';
resolve(this.role);
});
},
// Set user's information
setInfo(partial: Partial<UserState>) {
if (partial.avatar !== undefined) {
partial.avatar = resolveAvatarUrl(partial.avatar);
}
this.$patch(partial);
},
// Reset user's information
resetInfo() {
this.$reset();
},
// Get user's information
async info() {
const profile = await authApi.profile();
const appStore = useAppStore();
this.setInfo({
name: profile.display_name || profile.username,
avatar: profile.avatar,
accountId: profile.identity,
role: profile.role_code,
menuCodes: profile.menu_codes,
});
if (appStore.menuFromServer) {
await appStore.fetchServerMenuConfig();
}
},
// Login
async login(loginForm: LoginData) {
try {
const res = await authApi.login(loginForm);
setToken(res.access_token);
} catch (err) {
clearToken();
throw err;
}
},
logoutCallBack() {
const appStore = useAppStore();
this.resetInfo();
clearToken();
removeRouteListener();
appStore.clearServerMenu();
},
// Logout
async logout() {
try {
await Promise.resolve();
} finally {
this.logoutCallBack();
}
},
},
});
export default useUserStore;

View File

@@ -0,0 +1,20 @@
export type RoleType = string;
export interface UserState {
name?: string;
avatar?: string;
job?: string;
organization?: string;
location?: string;
email?: string;
introduction?: string;
personalWebsite?: string;
jobName?: string;
organizationName?: string;
locationName?: string;
phone?: string;
registrationDate?: string;
accountId?: string;
certification?: number;
role: RoleType;
menuCodes: string[];
}

View File

@@ -0,0 +1,10 @@
import type { CallbackDataParams } from 'echarts/types/dist/shared';
export interface ToolTipFormatterParams extends CallbackDataParams {
axisDim: string;
axisIndex: number;
axisType: string;
axisId: string;
axisValue: string;
axisValueLabel: string;
}

View File

@@ -0,0 +1,37 @@
export interface AnyObject {
[key: string]: unknown;
}
export interface Options {
value: unknown;
label: string;
}
export interface NodeOptions extends Options {
children?: NodeOptions[];
}
export interface GetParams {
body: null;
type: string;
url: string;
}
export interface PostData {
body: string;
type: string;
url: string;
}
export interface Pagination {
current: number;
pageSize: number;
total?: number;
}
export type TimeRanger = [string, string];
export interface GeneralChart {
xAxis: string[];
data: Array<{ name: string; value: number[] }>;
}

View File

@@ -0,0 +1,19 @@
const TOKEN_KEY = 'token';
const isLogin = () => {
return !!localStorage.getItem(TOKEN_KEY);
};
const getToken = () => {
return localStorage.getItem(TOKEN_KEY);
};
const setToken = (token: string) => {
localStorage.setItem(TOKEN_KEY, token);
};
const clearToken = () => {
localStorage.removeItem(TOKEN_KEY);
};
export { clearToken, getToken, isLogin, setToken };

View File

@@ -0,0 +1,3 @@
const debug = import.meta.env.MODE !== 'production';
export default debug;

View File

@@ -0,0 +1,19 @@
import axios from 'axios';
import type { App, ComponentPublicInstance } from 'vue';
export default function setupErrorReport(app: App, baseUrl: string) {
if (!baseUrl) {
return;
}
app.config.errorHandler = (
err: unknown,
instance: ComponentPublicInstance | null,
info: string,
) => {
axios.post(`${baseUrl}/report-error`, {
err,
instance,
info,
});
};
}

View File

@@ -0,0 +1,27 @@
export function addEventListen(
target: Window | HTMLElement,
event: string,
handler: EventListenerOrEventListenerObject,
capture = false,
) {
if (
target.addEventListener &&
typeof target.addEventListener === 'function'
) {
target.addEventListener(event, handler, capture);
}
}
export function removeEventListen(
target: Window | HTMLElement,
event: string,
handler: EventListenerOrEventListenerObject,
capture = false,
) {
if (
target.removeEventListener &&
typeof target.removeEventListener === 'function'
) {
target.removeEventListener(event, handler, capture);
}
}

View File

@@ -0,0 +1,23 @@
type TargetContext = '_self' | '_parent' | '_blank' | '_top';
export const openWindow = (
url: string,
opts?: { target?: TargetContext; [key: string]: any },
) => {
const { target = '_blank', ...others } = opts || {};
window.open(
url,
target,
Object.entries(others)
.reduce((preValue: string[], curValue) => {
const [key, value] = curValue;
return [...preValue, `${key}=${value}`];
}, [])
.join(','),
);
};
export const regexUrl =
/^(?!mailto:)(?:(?:http|https|ftp):\/\/)(?:\S+(?::\S*)?@)?(?:(?:(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[0-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]+-?)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]+-?)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))|localhost)(?::\d{2,5})?(?:(\/|\?|#)[^\s]*)?$/i;
export default null;

View File

@@ -0,0 +1,53 @@
const opt = Object.prototype.toString;
export function isArray(obj: any): obj is any[] {
return opt.call(obj) === '[object Array]';
}
export function isObject(obj: any): obj is { [key: string]: any } {
return opt.call(obj) === '[object Object]';
}
export function isString(obj: any): obj is string {
return opt.call(obj) === '[object String]';
}
export function isNumber(obj: any): obj is number {
return opt.call(obj) === '[object Number]' && !Number.isNaN(obj);
}
export function isRegExp(obj: any) {
return opt.call(obj) === '[object RegExp]';
}
export function isFile(obj: any): obj is File {
return opt.call(obj) === '[object File]';
}
export function isBlob(obj: any): obj is Blob {
return opt.call(obj) === '[object Blob]';
}
export function isUndefined(obj: any): obj is undefined {
return obj === undefined;
}
export function isNull(obj: any): obj is null {
return obj === null;
}
export function isFunction(obj: any): obj is (...args: any[]) => any {
return typeof obj === 'function';
}
export function isEmptyObject(obj: any): boolean {
return isObject(obj) && Object.keys(obj).length === 0;
}
export function isExist(obj: any): boolean {
return obj || obj === 0;
}
export function isWindow(el: any): el is Window {
return el === window;
}

View File

@@ -0,0 +1,8 @@
export function parseUrlQuery(url: string): Record<string, string> {
const { searchParams } = new URL(url, 'http://localhost');
const query: Record<string, string> = {};
searchParams.forEach((value, key) => {
query[key] = value;
});
return query;
}

View File

@@ -0,0 +1,31 @@
/**
* Listening to routes alone would waste rendering performance.
* Use the publish-subscribe model for distribution management.
*/
import mitt, { type Handler } from 'mitt';
import type { RouteLocationNormalized } from 'vue-router';
const emitter = mitt();
const key = Symbol('ROUTE_CHANGE');
let latestRoute: RouteLocationNormalized;
export function setRouteEmitter(to: RouteLocationNormalized) {
emitter.emit(key, to);
latestRoute = to;
}
export function listenerRouteChange(
handler: (route: RouteLocationNormalized) => void,
immediate = true,
) {
emitter.on(key, handler as Handler);
if (immediate && latestRoute) {
handler(latestRoute);
}
}
export function removeRouteListener() {
emitter.off(key);
}

View File

@@ -0,0 +1,38 @@
<template>
<a-spin :loading="loading" style="width: 100%">
<a-grid :cols="{ xs: 1, sm: 2, lg: 5 }" :col-gap="16" :row-gap="16">
<a-grid-item v-for="item in cards" :key="item.key">
<a-card :bordered="false">
<a-statistic :title="item.label" :value="overview[item.key]" show-group-separator />
</a-card>
</a-grid-item>
</a-grid>
</a-spin>
</template>
<script setup lang="ts">
import { Message } from '@arco-design/web-vue';
import { onMounted, reactive, ref } from 'vue';
import { request } from '@/api/http';
type Overview = Record<'delivery_count' | 'staff_count' | 'user_count' | 'contract_count' | 'order_count', number>;
const loading = ref(false);
const overview = reactive<Overview>({ delivery_count: 0, staff_count: 0, user_count: 0, contract_count: 0, order_count: 0 });
const cards: Array<{ key: keyof Overview; label: string }> = [
{ key: 'delivery_count', label: '配送点' },
{ key: 'staff_count', label: '工作人员' },
{ key: 'user_count', label: '服务用户' },
{ key: 'contract_count', label: '配送合同' },
{ key: 'order_count', label: '燃气配送订单' },
];
onMounted(async () => {
loading.value = true;
try {
Object.assign(overview, await request<Overview>('/dashboard/overview'));
} catch (error) {
Message.error((error as Error).message);
} finally {
loading.value = false;
}
});
</script>

View File

@@ -0,0 +1,31 @@
<template>
<a-card title="订单状态统计" :bordered="false">
<a-spin :loading="loading">
<a-table :data="rows" :pagination="false" row-key="status">
<a-table-column title="订单状态" data-index="status" />
<a-table-column title="订单数量" data-index="count" />
</a-table>
</a-spin>
</a-card>
</template>
<script setup lang="ts">
import { Message } from '@arco-design/web-vue';
import { onMounted, ref } from 'vue';
import { request } from '@/api/http';
type Row = { status: number; count: number };
const loading = ref(false);
const rows = ref<Row[]>([]);
onMounted(async () => {
loading.value = true;
try {
const result = await request<{ orders_by_status: Row[] }>('/dashboard/reports');
rows.value = result.orders_by_status;
} catch (error) {
Message.error((error as Error).message);
} finally {
loading.value = false;
}
});
</script>

View File

@@ -0,0 +1,47 @@
<template>
<a-card title="用户邀请注册" :bordered="false">
<a-spin :loading="loading">
<a-space direction="vertical" align="center" fill :size="20">
<img v-if="dataUrl" :src="dataUrl" class="qrcode" alt="用户注册邀请二维码" />
<a-typography-text copyable>{{ registerUrl }}</a-typography-text>
<a-button type="primary" :disabled="!dataUrl" @click="download">下载二维码</a-button>
<a-alert>二维码根据当前气站 identity 实时生成不保存邀请记录</a-alert>
</a-space>
</a-spin>
</a-card>
</template>
<script setup lang="ts">
import { Message } from '@arco-design/web-vue';
import QRCode from 'qrcode';
import { onMounted, ref } from 'vue';
import { request } from '@/api/http';
const loading = ref(false);
const registerUrl = ref('');
const dataUrl = ref('');
onMounted(async () => {
loading.value = true;
try {
const result = await request<{ register_url: string }>('/invitation/qrcode');
registerUrl.value = result.register_url;
dataUrl.value = await QRCode.toDataURL(result.register_url, { width: 320, margin: 2 });
} catch (error) {
Message.error((error as Error).message);
} finally {
loading.value = false;
}
});
function download() {
const link = document.createElement('a');
link.href = dataUrl.value;
link.download = '气站用户邀请二维码.png';
link.click();
}
</script>
<style scoped>
.qrcode { width: 320px; height: 320px; max-width: 100%; }
</style>

View File

@@ -0,0 +1,81 @@
<template>
<div class="banner">
<div class="banner-inner">
<a-carousel class="carousel" animation-name="fade">
<a-carousel-item v-for="item in carouselItem" :key="item.slogan">
<div :key="item.slogan" class="carousel-item">
<div class="carousel-title">{{ item.slogan }}</div>
<div class="carousel-sub-title">{{ item.subSlogan }}</div>
<img class="carousel-image" :src="item.image" />
</div>
</a-carousel-item>
</a-carousel>
</div>
</div>
</template>
<script lang="ts" setup>
import bannerImage from '@/assets/images/login-banner.png';
const carouselItem = [
{
slogan: '统一运营',
subSlogan: '覆盖气站、配送站、人员、用户与产品',
image: bannerImage,
},
{
slogan: '气体配送闭环',
subSlogan: '合同、气瓶、订单、轨迹、确认与支付',
image: bannerImage,
},
{
slogan: '资金安全',
subSlogan: '统一钱包、流水、退款与提现审核',
image: bannerImage,
},
];
</script>
<style lang="less" scoped>
.banner {
display: flex;
align-items: center;
justify-content: center;
&-inner {
flex: 1;
height: 100%;
}
}
.carousel {
height: 100%;
&-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
}
&-title {
color: var(--color-fill-1);
font-weight: 500;
font-size: 20px;
line-height: 28px;
}
&-sub-title {
margin-top: 8px;
color: var(--color-text-3);
font-size: 14px;
line-height: 22px;
}
&-image {
width: 320px;
margin-top: 30px;
}
}
</style>

View File

@@ -0,0 +1,160 @@
<template>
<div class="login-form-wrapper">
<div class="login-form-title">登录和气平台</div>
<div class="login-form-sub-title">使用平台管理员账户登录</div>
<a-form
ref="loginForm"
:model="userInfo"
class="login-form"
layout="vertical"
@submit="handleSubmit"
>
<a-form-item
field="username"
:rules="[{ required: true, message: '请输入用户名' }]"
:validate-trigger="['change', 'blur']"
hide-label
>
<a-input
v-model="userInfo.username"
placeholder="用户名"
>
<template #prefix>
<icon-user />
</template>
</a-input>
</a-form-item>
<a-form-item
field="password"
:rules="[{ required: true, message: '请输入密码' }]"
:validate-trigger="['change', 'blur']"
hide-label
>
<a-input-password
v-model="userInfo.password"
placeholder="密码"
allow-clear
>
<template #prefix>
<icon-lock />
</template>
</a-input-password>
</a-form-item>
<a-space :size="16" direction="vertical">
<div class="login-form-password-actions">
<a-checkbox
checked="rememberPassword"
:model-value="loginConfig.rememberPassword"
@change="setRememberPassword as any"
>
记住用户名
</a-checkbox>
</div>
<div v-if="errorMessage" class="login-form-error-msg">
{{ errorMessage }}
</div>
<a-button type="primary" html-type="submit" long :loading="loading">
登录
</a-button>
</a-space>
</a-form>
</div>
</template>
<script lang="ts" setup>
import { Message } from '@arco-design/web-vue';
import type { ValidatedError } from '@arco-design/web-vue/es/form/interface';
import { useStorage } from '@vueuse/core';
import { reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import type { LoginData } from '@/api/auth';
import useLoading from '@/hooks/loading';
import { useUserStore } from '@/store';
const router = useRouter();
const errorMessage = ref('');
const { loading, setLoading } = useLoading();
const userStore = useUserStore();
const loginConfig = useStorage('login-config', {
rememberPassword: true,
username: 'admin',
password: '',
});
const userInfo = reactive({
username: loginConfig.value.username,
password: loginConfig.value.password,
});
const handleSubmit = async ({
errors,
values,
}: {
errors: Record<string, ValidatedError> | undefined;
values: Record<string, any>;
}) => {
if (loading.value) return;
if (!errors) {
errorMessage.value = '';
setLoading(true);
try {
await userStore.login(values as LoginData);
const { redirect, ...othersQuery } = router.currentRoute.value.query;
router.push({
name: (redirect as string) || 'dashboard-overview',
query: {
...othersQuery,
},
});
Message.success('登录成功');
const { rememberPassword } = loginConfig.value;
const { username } = values;
loginConfig.value.username = rememberPassword ? username : '';
loginConfig.value.password = '';
} catch (err) {
errorMessage.value = (err as Error).message;
} finally {
setLoading(false);
}
}
};
const setRememberPassword = (value: boolean) => {
loginConfig.value.rememberPassword = value;
};
</script>
<style lang="less" scoped>
.login-form {
&-wrapper {
width: 320px;
}
&-title {
color: var(--color-text-1);
font-weight: 500;
font-size: 24px;
line-height: 32px;
}
&-sub-title {
color: var(--color-text-3);
font-size: 16px;
line-height: 24px;
}
&-error-msg {
height: 32px;
color: rgb(var(--red-6));
line-height: 32px;
}
&-password-actions {
display: flex;
justify-content: space-between;
}
&-register-btn {
color: var(--color-text-3) !important;
}
}
</style>

View File

@@ -0,0 +1,79 @@
<template>
<div class="container">
<div class="logo">
<img alt="和气平台" :src="logoUrl" />
<div class="logo-text">禾气气站管理系统</div>
</div>
<LoginBanner />
<div class="content">
<div class="content-inner">
<LoginForm />
</div>
<div class="footer">
<Footer />
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import logoUrl from '@/assets/logo.svg?url';
import Footer from '@/components/footer/index.vue';
import LoginBanner from './components/login-banner.vue';
import LoginForm from './components/login-form.vue';
</script>
<style lang="less" scoped>
.container {
display: flex;
height: 100vh;
.banner {
width: 550px;
background: linear-gradient(163.85deg, #1d2129 0%, #00308f 100%);
}
.content {
position: relative;
display: flex;
flex: 1;
align-items: center;
justify-content: center;
padding-bottom: 40px;
}
.footer {
position: absolute;
right: 0;
bottom: 0;
width: 100%;
}
}
.logo {
position: fixed;
top: 24px;
left: 22px;
z-index: 1;
display: inline-flex;
align-items: center;
&-text {
margin-right: 4px;
margin-left: 4px;
color: var(--color-fill-1);
font-size: 20px;
}
}
</style>
<style lang="less" scoped>
// responsive
@media (max-width: @screen-lg) {
.container {
.banner {
width: 25%;
}
}
}
</style>

View File

@@ -0,0 +1,21 @@
export default {
'login.form.title': 'Login to Arco Design Pro',
'login.form.userName.errMsg': 'Username cannot be empty',
'login.form.password.errMsg': 'Password cannot be empty',
'login.form.login.errMsg': 'Login error, refresh and try again',
'login.form.login.success': 'welcome to use',
'login.form.userName.placeholder': 'Username: admin',
'login.form.password.placeholder': 'Password: admin',
'login.form.rememberPassword': 'Remember password',
'login.form.forgetPassword': 'Forgot password',
'login.form.login': 'login',
'login.form.register': 'register account',
'login.banner.slogan1': 'Out-of-the-box high-quality template',
'login.banner.subSlogan1':
'Rich page templates, covering most typical business scenarios',
'login.banner.slogan2': 'Built-in solutions to common problems',
'login.banner.subSlogan2':
'Internationalization, routing configuration, state management everything',
'login.banner.slogan3': 'Access visualization enhancement tool AUX',
'login.banner.subSlogan3': 'Realize flexible block development',
};

View File

@@ -0,0 +1,19 @@
export default {
'login.form.title': '登录 Arco Design Pro',
'login.form.userName.errMsg': '用户名不能为空',
'login.form.password.errMsg': '密码不能为空',
'login.form.login.errMsg': '登录出错,轻刷新重试',
'login.form.login.success': '欢迎使用',
'login.form.userName.placeholder': '用户名admin',
'login.form.password.placeholder': '密码admin',
'login.form.rememberPassword': '记住密码',
'login.form.forgetPassword': '忘记密码',
'login.form.login': '登录',
'login.form.register': '注册账号',
'login.banner.slogan1': '开箱即用的高质量模板',
'login.banner.subSlogan1': '丰富的的页面模板,覆盖大多数典型业务场景',
'login.banner.slogan2': '内置了常见问题的解决方案',
'login.banner.subSlogan2': '国际化,路由配置,状态管理应有尽有',
'login.banner.slogan3': '接入可视化增强工具AUX',
'login.banner.subSlogan3': '实现灵活的区块式开发',
};

View File

@@ -0,0 +1,30 @@
<template>
<div class="content">
<a-result class="result" status="404" :subtitle="'not found'"> </a-result>
<div class="operation-row">
<a-button key="back" type="primary" @click="back"> back </a-button>
</div>
</div>
</template>
<script lang="ts" setup>
import { useRouter } from 'vue-router';
const router = useRouter();
const back = () => {
// warning: Go to the node that has the permission
router.push({ name: 'Workplace' });
};
</script>
<style scoped lang="less">
.content {
// padding-top: 100px;
position: absolute;
top: 50%;
left: 50%;
margin-left: -95px;
margin-top: -121px;
text-align: center;
}
</style>

View File

@@ -0,0 +1,16 @@
<template>
<div></div>
</template>
<script lang="ts" setup>
import { useRoute, useRouter } from 'vue-router';
const router = useRouter();
const route = useRoute();
const gotoPath = route.params.path as string;
router.replace({ path: gotoPath });
</script>
<style scoped lang="less"></style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,147 @@
<template>
<a-card :title="definition.title" :bordered="false">
<template #extra><a-button @click="load">刷新</a-button></template>
<a-form :model="filters" layout="inline" class="filters" @submit.prevent="load">
<a-form-item label="关键字"><a-input v-model="filters.keyword" allow-clear placeholder="服务端筛选" /></a-form-item>
<a-button type="primary" html-type="submit">查询</a-button>
</a-form>
<a-table :data="list" :loading="loading" :pagination="false" row-key="identity">
<template #columns>
<a-table-column title="业务标识" data-index="identity" :width="220" ellipsis tooltip />
<a-table-column v-for="field in displayFields" :key="field.key" :title="field.label" :data-index="field.key" ellipsis tooltip />
<a-table-column title="操作" :width="90"><template #cell="{ record }"><a-button size="mini" @click="openDetail(record)">详情</a-button></template></a-table-column>
</template>
</a-table>
<div class="pagination"><a-pagination :total="total" :current="page" :page-size="pageSize" show-total @change="changePage" /></div>
</a-card>
<a-drawer :visible="detailVisible" title="详情" :width="540" @cancel="detailVisible = false">
<a-descriptions :column="1" bordered><a-descriptions-item v-for="[key, value] in detailEntries" :key="key" :label="key">{{ value ?? '-' }}</a-descriptions-item></a-descriptions>
<a-space v-if="definition.detailActions?.length" class="detail-actions">
<a-button v-for="action in definition.detailActions" :key="action.name" :status="action.payload?.status === 'rejected' ? 'danger' : 'normal'" type="primary" @click="openDetailAction(action)">{{ action.name }}</a-button>
</a-space>
</a-drawer>
<a-modal :visible="actionVisible" :title="activeAction?.name" @cancel="actionVisible = false" @ok="submitDetailAction">
<a-form :model="actionForm" layout="vertical">
<a-form-item v-for="field in activeAction?.fields" :key="field.key" :label="field.label" :required="field.required">
<a-textarea v-if="field.type === 'textarea'" v-model="actionForm[field.key]" />
<a-input v-else v-model="actionForm[field.key]" />
</a-form-item>
</a-form>
</a-modal>
</template>
<script setup lang="ts">
import { Message } from '@arco-design/web-vue';
import { computed, onMounted, reactive, ref } from 'vue';
import { resourceApi } from '@/api/resource';
import { buildResourcePayload, isMissingField } from '@/api/resource-form';
import type { DetailAction, ResourceUiDefinition } from '@/api/resources';
type Row = Record<string, unknown>;
const props = defineProps<{ definition: ResourceUiDefinition }>();
const loading = ref(false);
const page = ref(1);
const pageSize = 20;
const total = ref(0);
const list = ref<Row[]>([]);
const filters = reactive({ keyword: '' });
const detail = ref<Row>({});
const detailVisible = ref(false);
const actionVisible = ref(false);
const activeAction = ref<DetailAction>();
const actionForm = reactive<Record<string, any>>({});
const displayFields = computed(() =>
props.definition.fields.filter((field) => field.key !== 'status'),
);
const detailEntries = computed(() =>
Object.entries(detail.value).filter(
([key]) => key !== 'id' && !key.endsWith('_id'),
),
);
async function load() {
loading.value = true;
try {
const result = await resourceApi.list<Row>(
props.definition.resource,
page.value,
pageSize,
filters.keyword ? { keyword: filters.keyword } : {},
);
list.value = result.list;
total.value = result.total;
} catch (error) {
Message.error((error as Error).message);
} finally {
loading.value = false;
}
}
async function openDetail(row: Row) {
try {
detail.value = await resourceApi.detail<Row>(
props.definition.resource,
String(row.identity),
);
detailVisible.value = true;
} catch (error) {
Message.error((error as Error).message);
}
}
function openDetailAction(action: DetailAction) {
activeAction.value = action;
for (const field of action.fields) actionForm[field.key] = undefined;
actionVisible.value = true;
}
async function submitDetailAction() {
const action = activeAction.value;
if (!action) return;
if (
action.fields.some(
(field) => field.required && isMissingField(actionForm[field.key]),
)
) {
Message.warning('请填写必填字段');
return;
}
try {
const payload = {
...action.payload,
...buildResourcePayload(action.fields, actionForm),
};
await resourceApi.create(
action.resource.replace(':identity', String(detail.value.identity)),
payload,
);
Message.success('审批完成');
actionVisible.value = false;
await openDetail(detail.value);
await load();
} catch (error) {
Message.error((error as Error).message);
}
}
async function changePage(next: number) {
page.value = next;
await load();
}
onMounted(load);
</script>
<style scoped lang="less">
.filters {
margin-bottom: 16px;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.detail-actions {
margin-top: 16px;
}
</style>

View File

@@ -0,0 +1,23 @@
<template>
<TreePage
v-if="definition.pageKind === 'tree'"
:key="String(route.name)"
:definition="definition"
/>
<CrudListPage
v-else
:key="String(route.name)"
:definition="definition"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { getResource } from '@/api/resources';
import CrudListPage from './CrudListPage.vue';
import TreePage from './TreePage.vue';
const route = useRoute();
const definition = computed(() => getResource(String(route.meta.resource)));
</script>

View File

@@ -0,0 +1,187 @@
<template>
<a-card :title="definition.title" :bordered="false">
<template #extra>
<a-space>
<a-button @click="load">刷新</a-button>
<a-button v-if="canCreate" type="primary" @click="openCreate">新增</a-button>
</a-space>
</template>
<a-tree :data="tree" :loading="loading" :field-names="{ key: 'identity', title: 'name', children: 'children' }">
<template #title="node">
<a-space>
{{ node.title }}
<a-button v-if="canEdit" size="mini" @click.stop="openEdit(node)">编辑</a-button>
<a-button v-if="canChangeStatus" size="mini" @click.stop="confirmStatus(node)">{{ node.status === 1 ? '停用' : '启用' }}</a-button>
<a-button v-if="canArchive" size="mini" status="danger" @click.stop="confirmArchive(node)">归档</a-button>
</a-space>
</template>
</a-tree>
</a-card>
<a-drawer :visible="formVisible" :title="editingIdentity ? `编辑${definition.title}` : `新增${definition.title}`" :width="480" @cancel="formVisible = false" @ok="save">
<a-form :model="form" layout="vertical">
<a-form-item v-for="field in definition.fields" :key="field.key" :label="field.label" :required="field.required">
<a-input-number v-if="field.type === 'number' || field.type === 'money'" v-model="form[field.key]" :precision="field.type === 'money' ? 2 : 0" />
<a-switch v-else-if="field.type === 'boolean'" v-model="form[field.key]" />
<a-select v-else-if="field.type === 'identity'" v-model="form[field.key]" allow-clear allow-search>
<a-option v-for="option in list.filter((item) => item.identity !== editingIdentity)" :key="option.identity" :value="option.identity">
{{ option.name ?? option.group_code ?? option.identity }}
</a-option>
</a-select>
<a-input v-else v-model="form[field.key]" />
</a-form-item>
</a-form>
</a-drawer>
</template>
<script setup lang="ts">
import { Message, Modal } from '@arco-design/web-vue';
import { computed, onMounted, reactive, ref } from 'vue';
import { resourceApi } from '@/api/resource';
import { buildResourcePayload, isMissingField } from '@/api/resource-form';
import type { ResourceUiDefinition } from '@/api/resources';
import { useUserStore } from '@/store';
type Node = Record<string, unknown> & {
identity: string;
parent_identity?: string;
children: Node[];
};
const props = defineProps<{ definition: ResourceUiDefinition }>();
const userStore = useUserStore();
const loading = ref(false);
const list = ref<Node[]>([]);
const formVisible = ref(false);
const editingIdentity = ref('');
const form = reactive<Record<string, any>>({});
const rootAllowed = computed(
() => props.definition.name !== 'platform_menu' || userStore.role === 'root',
);
const canCreate = computed(
() => props.definition.canCreate && rootAllowed.value,
);
const canEdit = computed(() => props.definition.canEdit && rootAllowed.value);
const canChangeStatus = computed(
() => props.definition.canChangeStatus && rootAllowed.value,
);
const canArchive = computed(
() => props.definition.canArchive && rootAllowed.value,
);
const tree = computed(() => {
const byIdentity = new Map<string, Node>();
const roots: Node[] = [];
for (const item of list.value) {
// Arco Tree 把 data.icon 当作图标渲染函数;平台菜单接口返回的是字符串图标名,
// 直接透传会导致 renderFunc is not a function 并破坏后续路由渲染。
const { icon, ...data } = item;
byIdentity.set(item.identity, {
...data,
...(typeof icon === 'string' ? { icon_name: icon } : {}),
children: [],
});
}
for (const item of byIdentity.values()) {
const parent = item.parent_identity
? byIdentity.get(item.parent_identity)
: undefined;
if (parent) parent.children.push(item);
else roots.push(item);
}
return roots;
});
function reset(data?: Node) {
for (const field of props.definition.fields) {
const value = data?.[field.key];
form[field.key] = value == null ? undefined : value;
}
}
function confirmStatus(node: Node) {
const status = node.status === 1 ? 2 : 1;
Modal.warning({
title: status === 1 ? '确认启用' : '确认停用',
content: `确定要${status === 1 ? '启用' : '停用'}${String(node.name ?? node.identity)}”吗?`,
onOk: async () => {
try {
await resourceApi.updateStatus(
props.definition.resource,
node.identity,
status,
);
Message.success('状态已更新');
await load();
} catch (error) {
Message.error((error as Error).message);
}
},
});
}
function openCreate() {
editingIdentity.value = '';
reset();
formVisible.value = true;
}
function openEdit(node: Node) {
editingIdentity.value = node.identity;
reset(node);
formVisible.value = true;
}
async function save() {
if (
props.definition.fields.some(
(field) => field.required && isMissingField(form[field.key]),
)
) {
Message.warning('请填写必填字段');
return;
}
try {
const payload = buildResourcePayload(props.definition.fields, form);
if (editingIdentity.value)
await resourceApi.update(
props.definition.resource,
editingIdentity.value,
payload,
);
else await resourceApi.create(props.definition.resource, payload);
formVisible.value = false;
await load();
} catch (error) {
Message.error((error as Error).message);
}
}
function confirmArchive(node: Node) {
Modal.warning({
title: '确认归档',
content: `归档“${String(node.name ?? node.identity)}”后,其历史数据仍会保留。`,
onOk: async () => {
try {
await resourceApi.archive(props.definition.resource, node.identity);
Message.success('已归档');
await load();
} catch (error) {
Message.error((error as Error).message);
}
},
});
}
async function load() {
loading.value = true;
try {
list.value = (
await resourceApi.list<Node>(props.definition.resource, 1, 500)
).list;
} catch (error) {
Message.error((error as Error).message);
} finally {
loading.value = false;
}
}
onMounted(load);
</script>

1
frontend/gas_admin/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />